├── poefixer ├── extra │ ├── __init__.py │ ├── example_queries.sql │ ├── logger.py │ └── sample_data.py ├── postprocess │ ├── __init__.py │ ├── currency.py │ └── currency_names.py ├── __init__.py ├── __version__.py ├── stashapi.py └── db.py ├── requirements.txt ├── Makefile ├── Changelog ├── scripts ├── currency_abbreviations.pl ├── sample_api_reader.py └── fixer.py ├── LICENSE ├── tests ├── test_db.py └── test_summary.py ├── .gitignore ├── setup.py └── README.md /poefixer/extra/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poefixer/postprocess/__init__.py: -------------------------------------------------------------------------------- 1 | from .currency import * 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyMySQL>=0.9.2 2 | python-rapidjson>=0.6.3 3 | requests>=2.19.1 4 | SQLAlchemy>=1.2.10 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pip install -r requirements.txt 3 | 4 | test: 5 | PYTHONPATH=. py.test tests 6 | 7 | .PHONY: init test 8 | 9 | -------------------------------------------------------------------------------- /poefixer/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | See poeapi.py for more details on how to use this module. 3 | """ 4 | 5 | from .stashapi import * 6 | from .db import * 7 | -------------------------------------------------------------------------------- /poefixer/__version__.py: -------------------------------------------------------------------------------- 1 | # See stashapi.py and __init__.py for more details 2 | 3 | VERSION = (1, 2, 0) 4 | 5 | __version__ = '.'.join(map(str, VERSION)) 6 | -------------------------------------------------------------------------------- /Changelog: -------------------------------------------------------------------------------- 1 | # See git histoy for more detail, of course. 2 | 3 | 2018-08-08 - 1.1 Huge improvements and ready for others to start using. 4 | 5 | * A stand-along currency summarizing tool 6 | * Ability to query poe.ninja for current Id 7 | * Start of a test suite 8 | * Initial DB structure somewhat stable 9 | * League handling fixed 10 | * Curency abbreviations should not have been so hard! 11 | * Debug and info logging sorted 12 | * Basic chaos equivalents are calculated 13 | * Probably lots of bugs! 14 | 15 | 2018-08-01 - 1.0 Initial version 16 | -------------------------------------------------------------------------------- /scripts/currency_abbreviations.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | # 3 | 4 | use HTML::Entities qw(decode_entities); 5 | use LWP::Simple qw(get); 6 | 7 | use v5.20.0; 8 | use strict; 9 | 10 | my $page = get('http://currency.poe.trade/tags'); 11 | my $a = 0; # Alternation between name and abbrev 12 | my $name; 13 | for my $line (split /\r?\n/, $page) { 14 | if ($line =~ /\]*\>\s*(.*?)\s*\<\/td\b[^\>]*\>/) { 15 | if (++$a % 2 == 0) { 16 | say qq{"}.decode_entities($_).qq{": "$name",} for (split /\s*,\s*/, $1); 17 | } else { 18 | ($name = decode_entities($1)) =~ s/\]*\>\s*// 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /poefixer/extra/example_queries.sql: -------------------------------------------------------------------------------- 1 | # Show the most recent sales that either caused us to re-calculate currency 2 | # values or had some meaninful conversion into chaos orbs (e.g. not generic 3 | # items being sold for chaos) 4 | select 5 | substr(sale.name, 1, 17) as `name`, 6 | substr(item.league, 1, 10) as `league`, 7 | sale.sale_amount as `$`, 8 | round(sale.sale_amount_chaos,2) as `$c`, 9 | substr(sale.sale_currency,1,15) as `currency`, 10 | round(sale.sale_amount_chaos/sale_amount,2) as `cur price` 11 | from sale 12 | inner join item on sale.item_id = item.id 13 | where 14 | (sale.is_currency = 1 or sale.sale_currency != 'Chaos Orb') 15 | order by sale.id desc 16 | limit 30; 17 | -------------------------------------------------------------------------------- /poefixer/extra/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | A generic logger for poefixer 3 | """ 4 | 5 | 6 | import logging 7 | 8 | 9 | def get_poefixer_logger(level=logging.INFO): 10 | """ 11 | Return a logger for this application. 12 | 13 | Logging `level` is the only parameter and should be one of the logging 14 | module's defined levels such as `logging.INFO` 15 | """ 16 | 17 | logger = logging.getLogger('poefixer') 18 | logger.setLevel(level) 19 | ch = logging.StreamHandler() 20 | ch.setLevel(level) 21 | formatter = logging.Formatter('%(asctime)s:%(name)s:%(levelname)s: %(message)s') 22 | ch.setFormatter(formatter) 23 | logger.addHandler(ch) 24 | 25 | return logger 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Aaron Sherman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """A unittest for poefixer.db""" 4 | 5 | import logging 6 | import unittest 7 | import collections 8 | 9 | import poefixer 10 | from poefixer.extra.sample_data import sample_stash_data 11 | 12 | 13 | class TestPoefixerDb(unittest.TestCase): 14 | 15 | def _get_default_db(self): 16 | db = poefixer.PoeDb(db_connect='sqlite:///:memory:') 17 | db.create_database() 18 | return db 19 | 20 | def test_initial_setup(self): 21 | db = self._get_default_db() 22 | 23 | def test_insert_no_items(self): 24 | db = self._get_default_db() 25 | stashes = self._sample_stashes() 26 | for stash in stashes: 27 | db.insert_api_stash(stash, with_items=False) 28 | 29 | def test_insert_with_items(self): 30 | db = self._get_default_db() 31 | stashes = self._sample_stashes() 32 | for stash in stashes: 33 | db.insert_api_stash(stash, with_items=True) 34 | 35 | def _sample_stashes(self): 36 | return [poefixer.ApiStash(s) for s in sample_stash_data()] 37 | 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | 42 | # vim: et:sts=4:sw=4:ai: 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # editor droppings 107 | *~ 108 | .*.swp 109 | -------------------------------------------------------------------------------- /scripts/sample_api_reader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Trivial API reader/writer for testing 5 | """ 6 | 7 | 8 | import json 9 | import logging 10 | import argparse 11 | 12 | import requests 13 | 14 | import poefixer 15 | import poefixer.extra.logger as plogger 16 | 17 | 18 | DEFAULT_DSN='sqlite:///:memory:' 19 | 20 | 21 | def parse_args(): 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument( 24 | '--verbose', action='store_true', help='Verbose output') 25 | parser.add_argument( 26 | '--debug', action='store_true', help='Debugging output') 27 | parser.add_argument( 28 | '-d', '--database-dsn', action='store', 29 | default=DEFAULT_DSN, 30 | help='Database connection string for SQLAlchemy') 31 | parser.add_argument( 32 | '--most-recent', action='store_true', 33 | help='Consult poe.ninja to find latest ID') 34 | parser.add_argument( 35 | 'next_id', action='store', nargs='?', 36 | help='The next id to start at') 37 | return parser.parse_args() 38 | 39 | def pull_data(database_dsn, next_id, most_recent, logger): 40 | """Grab data from the API and insert into the DB""" 41 | 42 | if most_recent: 43 | if next_id: 44 | raise ValueError("Cannot provide next_id with most-recent flag") 45 | result = requests.get('http://poe.ninja/api/Data/GetStats') 46 | result.raise_for_status() 47 | data = json.loads(result.text) 48 | next_id = data['next_change_id'] 49 | 50 | db = poefixer.PoeDb(db_connect=database_dsn, logger=logger) 51 | api = poefixer.PoeApi(logger=logger, next_id=next_id) 52 | 53 | db.create_database() 54 | 55 | while True: 56 | for stash in api.get_next(): 57 | logger.debug("Inserting stash...") 58 | db.insert_api_stash(stash, with_items=True) 59 | logger.info("Stash pass complete.") 60 | db.session.commit() 61 | 62 | 63 | if __name__ == '__main__': 64 | options = parse_args() 65 | 66 | if options.debug: 67 | level = 'DEBUG' 68 | elif options.verbose: 69 | level = 'INFO' 70 | else: 71 | level = 'WARNING' 72 | logging.basicConfig(level=level) 73 | logger = plogger.get_poefixer_logger(level) 74 | 75 | pull_data( 76 | database_dsn=options.database_dsn, 77 | next_id=options.next_id, 78 | most_recent=options.most_recent, 79 | logger=logger) 80 | 81 | 82 | # vim: et:sw=4:sts=4:ai: 83 | -------------------------------------------------------------------------------- /scripts/fixer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Perform analysis on the PoE pricing database for various purposes 5 | """ 6 | 7 | 8 | import argparse 9 | import cProfile 10 | import logging 11 | import pstats 12 | import re 13 | import sys 14 | import time 15 | 16 | import sqlalchemy 17 | 18 | import poefixer 19 | import poefixer.postprocess.currency as currency 20 | import poefixer.extra.logger as plogger 21 | 22 | 23 | DEFAULT_DSN='sqlite:///:memory:' 24 | 25 | 26 | def parse_args(): 27 | parser = argparse.ArgumentParser( 28 | formatter_class=argparse.RawDescriptionHelpFormatter) 29 | parser.add_argument( 30 | '-d', '--database-dsn', 31 | action='store', default=DEFAULT_DSN, 32 | help='Database connection string for SQLAlchemy') 33 | parser.add_argument( 34 | '-v', '--verbose', 35 | action='store_true', help='Verbose output') 36 | parser.add_argument( 37 | '--debug', 38 | action='store_true', help='Debugging output') 39 | parser.add_argument( 40 | '--trace', 41 | action='store_true', help='Diagnostic code profiling mode') 42 | parser.add_argument( 43 | 'mode', 44 | choices=('currency',), # more to come... 45 | nargs=1, 46 | action='store', help='Mode to run in.') 47 | add_currency_arguments(parser) 48 | return parser.parse_args() 49 | 50 | def add_currency_arguments(argsparser): 51 | """Add arguments relevant only to the currency processing""" 52 | 53 | argsparser.add_argument( 54 | '--start-time', action='store', type=int, 55 | help='The first Unix timestamp to process') 56 | argsparser.add_argument( 57 | '--continuous', action='store_true', 58 | help='Once processing is complete, start over') 59 | argsparser.add_argument( 60 | '--limit', 61 | action='store', type=int, help='Limit processing to this many records') 62 | 63 | def do_fixer(db, options, logger): 64 | mode = options.mode 65 | assert len(mode) == 1, "Only one mode allowed" 66 | mode = mode[0] 67 | if mode == 'currency': 68 | # Crunch and update currency values 69 | start_time = options.start_time 70 | continuous = options.continuous 71 | limit = options.limit 72 | currency.CurrencyPostprocessor( 73 | db=db, 74 | start_time=start_time, 75 | continuous=continuous, 76 | limit=limit, 77 | logger=logger).do_currency_postprocessor() 78 | else: 79 | raise ValueError("Expected execution mode, got: " + mode) 80 | 81 | class FixerProfiler(cProfile.Profile): 82 | def __init__(self, *args, **kwargs): 83 | super().__init__(*args, **kwargs) 84 | self.enable() 85 | 86 | def fixer_report(self): 87 | self.disable() 88 | #buffer = io.StringIO() 89 | pstats.Stats(self).sort_stats('cumulative').print_stats() 90 | # = pstats.Stats(pr, stream=s).sort_stats(sortby) 91 | #ps.print_stats() 92 | #print(s.getvalue()) 93 | 94 | 95 | 96 | if __name__ == '__main__': 97 | options = parse_args() 98 | echo = False 99 | 100 | if options.debug: 101 | loglevel = 'DEBUG' 102 | echo = True 103 | elif options.verbose: 104 | loglevel = 'INFO' 105 | else: 106 | loglevel = 'WARNING' 107 | logger = plogger.get_poefixer_logger(loglevel) 108 | logger.debug("Set logging level: %s" % loglevel) 109 | 110 | db = poefixer.PoeDb( 111 | db_connect=options.database_dsn, logger=logger, echo=echo) 112 | db.session.bind.execution_options(stream_results=True) 113 | 114 | if options.trace: 115 | profiler = FixerProfiler() 116 | do_fixer(db, options, logger) 117 | if options.trace: 118 | profiler.fixer_report() 119 | 120 | 121 | # vim: et:sw=4:sts=4:ai: 122 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # -*- coding: utf-8 -*- 4 | 5 | # Note: To use the 'upload' functionality of this file, you must: 6 | # $ pip install twine 7 | 8 | # Based on https://github.com/kennethreitz/setup.py 9 | # 10 | # originally by Kenneth Reitz (https://github.com/kennethreitz) 11 | 12 | import io 13 | import os 14 | import sys 15 | from shutil import rmtree 16 | 17 | from setuptools import find_packages, setup, Command 18 | 19 | # Package meta-data. 20 | NAME = 'poefixer' 21 | DESCRIPTION = 'An API and associated tools for Path of Exile pricing' 22 | URL = 'https://github.com/ajs/poefixer' 23 | EMAIL = 'ajs@ajs.com' 24 | AUTHOR = 'Aaron Sherman' 25 | REQUIRES_PYTHON = '>=3.6.0' 26 | VERSION = None 27 | 28 | REQUIRED = [ 29 | # General requirements. See requirements.txt for latest 30 | # tested versioning. 31 | 'PyMySQL>=0.9', 32 | 'python-rapidjson', 33 | 'requests>=2.0.0', 34 | 'SQLAlchemy>=1.2.0', 35 | ] 36 | 37 | EXTRAS = { } 38 | 39 | # The rest you shouldn't have to touch too much :) 40 | # ------------------------------------------------ 41 | # Except, perhaps the License and Trove Classifiers! 42 | # If you do change the License, remember to change the Trove Classifier for that! 43 | 44 | here = os.path.abspath(os.path.dirname(__file__)) 45 | 46 | # Import the README and use it as the long-description. 47 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 48 | try: 49 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 50 | long_description = '\n' + f.read() 51 | except FileNotFoundError: 52 | long_description = DESCRIPTION 53 | 54 | # Load the package's __version__.py module as a dictionary. 55 | about = {} 56 | if not VERSION: 57 | with open(os.path.join(here, NAME, '__version__.py')) as f: 58 | exec(f.read(), about) 59 | else: 60 | about['__version__'] = VERSION 61 | 62 | 63 | class UploadCommand(Command): 64 | """Support setup.py upload.""" 65 | 66 | description = 'Build and publish the package.' 67 | user_options = [] 68 | 69 | @staticmethod 70 | def status(s): 71 | """Prints things in bold.""" 72 | print('\033[1m{0}\033[0m'.format(s)) 73 | 74 | def initialize_options(self): 75 | pass 76 | 77 | def finalize_options(self): 78 | pass 79 | 80 | def run(self): 81 | try: 82 | self.status('Removing previous builds…') 83 | rmtree(os.path.join(here, 'dist')) 84 | except OSError: 85 | pass 86 | 87 | self.status('Building Source and Wheel (universal) distribution…') 88 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 89 | 90 | self.status('Uploading the package to PyPI via Twine…') 91 | os.system('twine upload dist/*') 92 | 93 | self.status('Pushing git tags…') 94 | os.system('git tag v{0}'.format(about['__version__'])) 95 | os.system('git push --tags') 96 | 97 | sys.exit() 98 | 99 | 100 | # Where the magic happens: 101 | setup( 102 | name=NAME, 103 | version=about['__version__'], 104 | description=DESCRIPTION, 105 | long_description=long_description, 106 | #long_description_content_type='text/markdown', 107 | author=AUTHOR, 108 | author_email=EMAIL, 109 | python_requires=REQUIRES_PYTHON, 110 | url=URL, 111 | packages=find_packages(exclude=('tests',)), 112 | # If your package is a single module, use this instead of 'packages': 113 | # py_modules=['mypackage'], 114 | 115 | # entry_points={ 116 | # 'console_scripts': ['mycli=mymodule:cli'], 117 | # }, 118 | install_requires=REQUIRED, 119 | extras_require=EXTRAS, 120 | include_package_data=True, 121 | license='MIT', 122 | classifiers=[ 123 | # Trove classifiers 124 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 125 | 'License :: OSI Approved :: MIT License', 126 | 'Programming Language :: Python', 127 | 'Programming Language :: Python :: 3', 128 | 'Programming Language :: Python :: 3.6', 129 | 'Programming Language :: Python :: Implementation :: CPython', 130 | 'Programming Language :: Python :: Implementation :: PyPy' 131 | ], 132 | # $ setup.py publish support. 133 | cmdclass={ 134 | 'upload': UploadCommand, 135 | }, 136 | ) 137 | 138 | # vim: et:sw=4:sts=4:ai: 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # poefixer 2 | A Path of Exile Stash API reader and database manager 3 | 4 | ## Context 5 | 6 | Path of Exile is an action RPG video game with a massive in-game economy, mediated by external websites. To facilitate this interaction, the publisher maintains an HTTP/JSON API for access to the live feed of items and player pricing. This software is designed to harvest that API, store it to a local database and perform some basic post-processing to get a set of derivative data suitable for further analysis. 7 | 8 | ## Content 9 | 10 | This module consists of two major components, both of which are 11 | accessible from the top-level namespace or can be imported individually: 12 | 13 | * `stashapi` 14 | * `db` 15 | 16 | The *stashapi module* is the HTTP interface to public stash tab updates 17 | published by GGG. This is how Path of Exile trading sites get their data, 18 | though if you're not whitelisted, you will be rate restricted by default. 19 | 20 | The *db module* is what takes the objects created by stashapi and writes them 21 | to your database. This creates a _current_ (not time series) database of all 22 | stashes and items. 23 | 24 | ## Usage 25 | 26 | To load data: 27 | 28 | * Begin by creating a database somewhere. MySQL is the most 29 | heavily tested by the author, but you can try something else 30 | if you want. Most of the code relies on sqlalchemy, so it should 31 | generally just work... 32 | * Install the module. If you are running from source code, you 33 | can `pip install -e .` or you can just set the environment variable 34 | `PYTHONPATH=.` once or before each command. 35 | * Turn your database credentials into a URL of the form: 36 | `://:@/` 37 | * For MySQL, you will also want to include `?charset=utf8mb4` at 38 | the end of the URL. Also, mysql has many interface libraries, so 39 | you need to specify which to use. I recommend `mysql+pymysql` as 40 | the `db-type` at the front of the URL. 41 | * Run the data loader as a trial run: `scripts/sample_api_reader.py -d ` 42 | * If that works, kill it and start it up again from the most recent ID. 43 | You can find that ID at: https://poe.ninja/stats 44 | * Once that is running and pulling down data into your database, you will 45 | also need a currency processor running. This takes the raw data and 46 | creates the currency summary and sales tables. Run it like so: 47 | `scripts/fixer.py -d currency --verbose` 48 | * The currency script will exit when it's up-to-date 49 | by default, but you can provide the `--continuous` flag 50 | to tell it to keep going. 51 | 52 | These programs provide a basic database structure and will auto-instantiate 53 | tables that they need. However, they are also too slow to keep up with the 54 | entire output of the community! You would have to create a parallelized 55 | version of the `sample_api_reader.py` script and the currency processor for 56 | that, and that's beyond the scope of this project, mostly because doing 57 | so without being whielisted by GGG would hit their auto-rate-limiting 58 | thresholds, and I'm not yet a big enough fish to get on that list. 59 | 60 | Once you have some data processed, you will have the following tables to 61 | look over in the DB: 62 | 63 | * `stash` - One row per end-use public stash tab with info about user name 64 | and so-on 65 | * `item` - All the item data you could ever want, but in raw form! 66 | * `sale` - One row per item sale with digested currency info including 67 | chaos equivalence. 68 | * `currency_summary` - A mapping of (`from_currency`, `to_currency`, `league`) 69 | where from and to are currency names and the league 70 | name divides the differnt league-based subsets of the 71 | economy. There is only one row per unique combination, 72 | recording our most up-to-date understanding 73 | of the trading value of each currency. 74 | 75 | You can also peruse the sample queries in `poefixer/extra`. 76 | 77 | That being said, you can accomplish quite a bit, just by regularly updating 78 | your pull to the most recent `next_id` and re-running. If you just wish 79 | to analyze the market, this is more than sufficient, and will quickly give 80 | you gigabytes of market data! 81 | 82 | This isn't (yet) a full-featured end-user tool. It's really aimed at 83 | Python developers who wish to begin working with the Path of Exile API, 84 | and don't want to write the API and DB code themselves (I know I didn't!) 85 | 86 | -A 87 | -------------------------------------------------------------------------------- /poefixer/extra/sample_data.py: -------------------------------------------------------------------------------- 1 | """Sample data to be used for testing""" 2 | 3 | def sample_stash_data(): 4 | return [ 5 | {'id': '227fb59f186902743142e4f2e26f8cb3b9583e38bcab49ed802d5793667c45bc', 'public': True, 'accountName': 'ACCOUNT1', 6 | 'lastCharacterName': 'CHARACTER1', 'stash': 'Sell', 'stashType': 'PremiumStash', 'league': 'Incursion Event (IRE001)', 7 | 'items': [ 8 | {'verified': False, 'w': 2, 'h': 1, 'ilvl': 69, 'icon': 9 | 'http://web.poecdn.com/image/Art/2DItems/Belts/Belt4.png?scale=1&scaleIndex=0&w=2&h=1&v=da282d3a3d76fc0d14b882450c3ed2ae', 10 | 'league': 'Incursion Event (IRE001)', 'id': '5b4549df8f0c94683d08e42749cc1afd786afaf3b7a127690e4c22e50b74e03b', 11 | 'name': '<><><>Behemoth Lock', 'typeLine': 'Cloth Belt', 'identified': True, 'note': '~price 3 chaos', 12 | 'requirements': [{'name': 'Level', 'values': [['52', 0]], 'displayMode': 0}], 13 | 'implicitMods': ['23% increased Stun and Block Recovery'], 'explicitMods': ['+41 to Strength', '+97 to maximum Life', 14 | '+36% to Lightning Resistance', '11% increased Flask effect duration'], 'frameType': 2, 15 | 'category': {'accessories': ['belt']}, 'x': 10, 'y': 6, 'inventoryId': 'Stash1'}, 16 | {'verified': False, 'w': 1, 'h': 1, 'ilvl': 0, 'icon': 17 | 'http://web.poecdn.com/image/Art/2DItems/Gems/PowerSiphon.png?scale=1&scaleIndex=0&w=1&h=1&v=9650c4f94cef22ba419e0b0492fb4a8b', 18 | 'support': False, 'league': 'Incursion Event (IRE001)', 'id': 19 | '650bc10eac5cb05c601aedb99fa8b899633bce33604e6b2dfeef3fa2001ef2d0', 'name': '', 'typeLine': 'Power Siphon', 20 | 'identified': True, 'note': '~price 2 chaos', 'properties': [ {'name': 'Attack, Projectile', 'values': [], 'displayMode': 0}, 21 | {'name': 'Level', 'values': [['1', 0]], 'displayMode': 0, 'type': 5}, {'name': 'Mana Cost', 'values': [['7', 0]], 'displayMode': 0}, 22 | {'name': 'Effectiveness of Added Damage', 'values': [['125%', 0]], 'displayMode': 0}, {'name': 'Quality', 'values': [['+20%', 1]], 'displayMode': 0, 'type': 6}], 23 | 'additionalProperties': [ {'name': 'Experience', 'values': [['1/15249', 0]], 'displayMode': 2, 'progress': 6.557806773344055e-05, 'type': 20}], 24 | 'requirements': [ {'name': 'Level', 'values': [['12', 0]], 'displayMode': 0}, {'name': 'Int', 'values': [['33', 0]], 'displayMode': 1}], 25 | 'secDescrText': 'Fires your wand to unleash projectiles that fire toward enemies in front of you or to your sides, dealing increased damage and granting you a power charge if an enemy is killed by, or soon after, the hit.', 26 | 'explicitMods': [ 'Deals 125% of Base Damage', 'Fires 4 additional Projectiles', 'Culling Strike', '20% increased Damage', 27 | '20% chance to gain a Power Charge when Projectile Hits a Rare or Unique Enemy', '20% increased Critical Strike Chance per Power Charge', 28 | '+10% to Critical Strike Multiplier per Power Charge'], 29 | 'descrText': 'Place into an item socket of the right colour to gain this skill. Right click to remove from a socket.', 30 | 'frameType': 4, 'category': {'gems': ['activegem']}, 'x': 0, 'y': 0, 'inventoryId': 'Stash2'}, 31 | {'verified': False, 'w': 1, 'h': 1, 'ilvl': 0, 'icon': 32 | 'http://web.poecdn.com/image/Art/2DItems/Gems/VaalGems/VaalGroundslam.png?scale=1&scaleIndex=0&w=1&h=1&v=b639cf9fbe236d76ba0db71931344893', 33 | 'support': False, 'league': 'Incursion Event (IRE001)', 'id': 'fbb2725aa43b18a7b3d9f733d1e17b0e2844d2be0899bab13f53a21352a13af8', 34 | 'name': '', 'typeLine': 'Vaal Ground Slam', 'identified': True, 'note': '~price 1 chaos', 'corrupted': True, 35 | 'properties': [{'name': 'Vaal, Attack, AoE, Melee', 'values': [], 'displayMode': 0}, {'name': 'Level', 'values': [['1', 0]], 36 | 'displayMode': 0, 'type': 5}, {'name': 'Mana Cost', 'values': [['6', 0]], 'displayMode': 0}, 37 | {'name': 'Quality', 'values': [['+20%', 1]], 'displayMode': 0, 'type': 6}], 'additionalProperties': [{'name': 'Experience', 38 | 'values': [['1/70', 0]], 'displayMode': 2, 'progress': 0.014285714365541935, 'type': 20}], 'requirements': [{'name': 39 | 'Level', 'values': [['1', 0]], 'displayMode': 0}], 'secDescrText': 'The character slams the ground in front of them with their main hand weapon, creating a wave that travels forward and damages enemies with an increased chance to stun. The wave deals more damage to closer enemies. Only works with Staves, Axes or Maces.', 40 | 'explicitMods': ['25% reduced Enemy Stun Threshold', '30% increased Stun Duration on enemies', 'Deals up to 40% more Damage to closer targets'], 41 | 'descrText': 'Place into an item socket of the right colour to gain this skill. Right click to remove from a socket.', 42 | 'frameType': 4, 'category': {'gems': ['activegem']}, 'x': 0, 'y': 2, 'inventoryId': 'Stash3', 43 | 'vaal': {'baseTypeName': 'Ground Slam', 'properties': [{'name': 'Souls Per Use', 'values': [['15', 0]], 'displayMode': 0}, 44 | {'name': 'Can Store %0 Uses', 'values': [['3', 0]], 'displayMode': 3}, {'name': 'Soul Gain Prevention', 'values': [['2 sec', 0]], 45 | 'displayMode': 0}, {'name': 'Effectiveness of Added Damage', 'values': [['185%', 0]], 'displayMode': 0}], 'explicitMods': 46 | ['Deals 185% of Base Damage', 'Stuns Enemies', '230% increased Stun Duration on enemies', "Can't be Evaded", 47 | 'Deals up to 40% more Damage to closer targets'], 'secDescrText': 'The character slams the ground in front of them with their main hand weapon, creating a wave that travels in all directions that damages and stuns enemies. The wave deals more damage to closer enemies. Only works with Staves, Axes or Maces.'}}]}, 48 | {'id': '276b112975ebdc4909ce32618e5577e41028d66fe6d0be8e81d48f5c1229ac62', 'public': True, 'accountName': 'ACCOUNT2', 49 | 'lastCharacterName': 'CHARACTER2', 'stash': '$', 50 | 'stashType': 'PremiumStash', 'league': 'Incursion Event (IRE001)', 'items': [ 51 | {'verified': False, 'w': 1, 'h': 1, 'ilvl': 0, 52 | 'icon': 'http://web.poecdn.com/image/Art/2DItems/Divination/InventoryIcon.png?scale=1&scaleIndex=0&stackSize=1&w=1&h=1&v=a8ae131b97fad3c64de0e6d9f250d743', 53 | 'league': 'Incursion Event (IRE001)', 'id': '673e6ffaa26b26c32b5aa082a4f7c779a4c5fc9b79cd06c3d3086b0f02b11b23', 54 | 'name': '', 'typeLine': 'The Valkyrie', 'identified': True, 'note': '~price 4 chaos', 'properties': 55 | [{'name': 'Stack Size', 'values': [['1/8', 0]], 'displayMode': 0}], 'explicitMods': ['{Nemesis Item}'], 56 | 'flavourText': ['{The villain strikes,\r', 'the world is torn.\r', 'A war begins, a hero is born,\r', 57 | 'The nemesis sets the sky alight.\r', "A hero's sacrifice\r", 'sets everything right.\r', "- Drake's Epitaph}"], 58 | 'frameType': 6, 'stackSize': 1, 'maxStackSize': 8, 'artFilename': 'TheValkyrie', 'category': {'cards': 59 | []}, 'x': 1, 'y': 0, 'inventoryId': 'Stash1'}, 60 | {'verified': False, 'w': 1, 'h': 1, 'ilvl': 0, 61 | 'icon': 'http://web.poecdn.com/image/Art/2DItems/Divination/InventoryIcon.png?scale=1&scaleIndex=0&stackSize=1&w=1&h=1&v=a8ae131b97fad3c64de0e6d9f250d743', 62 | 'league': 'Incursion Event (IRE001)', 'id': '79ded662e9a97821472cbab30d2cd45ae88dc285d177d78f5b399820cd3394ab', 63 | 'name': '', 'typeLine': 'Abandoned Wealth', 'identified': True, 'note': '~price 50 chaos', 'properties': [{'name': 'Stack Size', 64 | 'values': [['1/5', 0]], 'displayMode': 0}], 'explicitMods': ['{3x Exalted Orb}'], 'flavourText': 65 | ['When the world burned, the greedy burned with it, while the clever left as paupers.'], 66 | 'frameType': 6, 'stackSize': 1, 'maxStackSize': 5, 'artFilename': 'AbandonedWealth', 'category': {'cards': []}, 67 | 'x': 2, 'y': 0, 'inventoryId': 'Stash2'}, 68 | {'verified': False, 'w': 2, 'h': 3, 'ilvl': 69, 69 | 'icon': 'http://web.poecdn.com/image/Art/2DItems/Armours/Shields/ShieldStrIntUnique2.png?scale=1&scaleIndex=0&w=2&h=3&v=3cc4e85d8f87166748078394fe27d218', 70 | 'league': 'Incursion Event (IRE001)', 'id': '16fa6d7591fa9fdb0cffa6359ee53256b4986230dc4457889868ee7c4c74e4a8', 71 | 'sockets': [{'group': 0, 'attr': 'I', 'sColour': 'B'}, 72 | {'group': 1, 'attr': 'S', 'sColour': 'R'}], 'name': '<><><>Springleaf', 73 | 'typeLine': 'Plank Kite Shield', 'identified': True, 'properties': [{'name': 'Chance to Block', 'values': 74 | [['22%', 0]], 'displayMode': 0, 'type': 15}, {'name': 'Armour', 'values': [['36', 1]], 'displayMode': 0, 'type': 16}, 75 | {'name': 'Energy Shield', 'values': [['8', 1]], 'displayMode': 0, 'type': 18}], 'requirements': [{'name': 'Level', 'values': [['7', 0]], 'displayMode': 0}], 76 | 'implicitMods': ['+4% to all Elemental Resistances'], 'explicitMods': ['99% increased Armour and Energy Shield', 77 | '50% reduced Freeze Duration on you', '3% of Life Regenerated per second', 78 | '3% of Life Regenerated per second while on Low Life'], 'flavourText': ['From death springs life.'], 'frameType': 3, 79 | 'category': {'armour': ['shield']}, 'x': 10, 'y': 9, 'inventoryId': 'Stash4', 'socketedItems': []}, 80 | ] 81 | }, 82 | ] 83 | -------------------------------------------------------------------------------- /tests/test_summary.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """A unittest for poefixer.db""" 4 | 5 | import logging 6 | import unittest 7 | import collections 8 | 9 | import poefixer 10 | import poefixer.extra.logger as plogger 11 | from poefixer.extra.sample_data import sample_stash_data 12 | from poefixer.postprocess.currency import CurrencyPostprocessor 13 | 14 | 15 | class CurrencyStep: 16 | """A linked list of currency conversion steps with prices""" 17 | 18 | currency = None 19 | price = None 20 | _price_float = None 21 | next_step = None 22 | 23 | def __init__(self, currency, price=None, next_step=None): 24 | self.currency = currency 25 | self.price = price 26 | self.next_step = next_step 27 | 28 | if price: 29 | if isinstance(price, str): 30 | if '/' in price: 31 | num, den = price.split('/') 32 | self._price_float = float(num)/float(den) 33 | return 34 | self._price_float = float(price) 35 | 36 | def _get_conversion_steps(self): 37 | if self.price: 38 | assert self.next_step, "If a price is set, must have next_step" 39 | yield (self.currency, self.next_step.currency, self.price) 40 | for sample in self.next_step._get_conversion_steps(): 41 | yield sample 42 | 43 | def get_sample_stashes(self): 44 | return sample_stashes(list(self._get_conversion_steps())) 45 | 46 | def conversion_price(self): 47 | price = self._price_float 48 | if self.next_step and self.next_step.price: 49 | price *= self.next_step.conversion_price() 50 | return price 51 | 52 | def conversion_goal(self): 53 | if self.next_step: 54 | return self.next_step.conversion_goal() 55 | return self.currency 56 | 57 | 58 | class TestPoefixerDb(unittest.TestCase): 59 | 60 | DB_URI = 'sqlite:///:memory:' 61 | 62 | def setUp(self): 63 | self.logger = plogger.get_poefixer_logger('WARNING') 64 | 65 | def _get_default_db(self): 66 | db = poefixer.PoeDb(db_connect=self.DB_URI, logger=self.logger) 67 | db.create_database() 68 | return db 69 | 70 | def _currency_postprocessor(self, db, recent=None): 71 | return CurrencyPostprocessor( 72 | db, start_time=None, recent=recent, logger=self.logger) 73 | 74 | def test_insert_currency(self): 75 | """ 76 | Test the currency summary handling on a list of 77 | 20 reasonable offers and one that is off by an order 78 | of magnitude. The processing should ignore the last one 79 | for summary purposes and produce a mean value that is 80 | in line with the majority of results. 81 | """ 82 | 83 | stashes = sample_stashes( 84 | [("Chaos Orb", "Exalted Orb", 0.01) for _ in range(20)] + 85 | [("Chaos Orb", "Exalted Orb", 100)]) 86 | 87 | db = self._get_default_db() 88 | 89 | for stash in stashes: 90 | db.insert_api_stash(stash, with_items=True) 91 | db.session.commit() 92 | 93 | cp = self._currency_postprocessor(db) 94 | cp.do_currency_postprocessor() 95 | 96 | query = db.session.query(poefixer.CurrencySummary) 97 | self.assertEqual(query.count(), 1) 98 | row = query.one_or_none() 99 | self.assertIsNotNone(row) 100 | self.assertEqual(row.from_currency, "Chaos Orb") 101 | self.assertEqual(row.to_currency, "Exalted Orb") 102 | self.assertEqual(row.count, 20) # The extreme value was discarded 103 | self.assertAlmostEqual(row.mean, 0.01) 104 | self.assertEqual(row.league, 'Standard') 105 | 106 | def test_actual_currency_name(self): 107 | """ 108 | Test the dynamic currency name handling based on data 109 | we have seen. 110 | """ 111 | 112 | from_c = "My Precious" 113 | stashes = sample_stashes([(from_c, "Exalted Orb", 0.01)]) 114 | 115 | db = self._get_default_db() 116 | 117 | cp = self._currency_processor_harness(db, stashes) 118 | 119 | # Now see if we'll use those new names 120 | (amt, cur) = cp.parse_note("~price 1 " + from_c) 121 | self.assertEqual(amt, 1) 122 | self.assertEqual(cur, from_c) 123 | # Try dashed version 124 | dashed_c = from_c.lower().replace(' ', '-') 125 | (amt, cur) = cp.parse_note("~price 1 " + dashed_c) 126 | self.assertEqual(amt, 1) 127 | # Make sure it goes back to the original 128 | self.assertEqual(cur, from_c) 129 | 130 | def _currency_processor_harness(self, db, stashes): 131 | for stash in stashes: 132 | db.insert_api_stash(stash, with_items=True) 133 | db.session.commit() 134 | 135 | cp = self._currency_postprocessor(db) 136 | cp.do_currency_postprocessor() 137 | # Second time picks up the new names we've seen 138 | cp.do_currency_postprocessor() 139 | 140 | return cp 141 | 142 | def _currency_valuation_check(self, conversion, alt_conversions=None): 143 | """Test a conversion price (conversion is a CurrencyStep list)""" 144 | 145 | stashes = conversion.get_sample_stashes() 146 | if alt_conversions: 147 | for alt_conv in alt_conversions: 148 | stashes += alt_conv.get_sample_stashes() 149 | 150 | db = self._get_default_db() 151 | cp = self._currency_processor_harness(db, stashes) 152 | # Repeat in order to re-add each currency, forcing 153 | # connections to be made. 154 | cp = self._currency_processor_harness(db, stashes) 155 | 156 | from_currency = conversion.currency 157 | to_currency = conversion.conversion_goal() 158 | self.assertEqual( 159 | to_currency, "Chaos Orb", 160 | "valuation test requires Chaos Orb target") 161 | price = conversion.conversion_price() 162 | 163 | self.assertIsNotNone(price) 164 | 165 | cp_price = cp.find_value_of(from_currency, "Standard", 1) 166 | 167 | self.assertIsNotNone( 168 | cp_price, 169 | "Conversion from %s->chaos" % from_currency) 170 | self.assertAlmostEqual(price, cp_price) 171 | 172 | def test_exalt_for_chaos(self): 173 | """Test pricing of ex -> chaos""" 174 | 175 | self._currency_valuation_check( 176 | CurrencyStep("Exalted Orb", 100, CurrencyStep("Chaos Orb"))) 177 | 178 | def text_exalt_for_chrom_for_chaos(self): 179 | """Test price of ex -> chrom -> chaos""" 180 | 181 | self._currency_valuation_check( 182 | CurrencyStep( 183 | "Exalted Orb", 500, CurrencyStep( 184 | "Chromatic Orb", "1/5", CurrencyStep("Chaos Orb")))) 185 | 186 | def test_currency_abbreviations(self, single=None, should_be=None): 187 | """ 188 | Make sure that abbreviated sale notes work 189 | 190 | If passed `single`, it is used as the one currency 191 | abbreviation to test. This is for regressions. 192 | 193 | If `single` is provided, then `should_be` can be set 194 | to the expected expansion. 195 | """ 196 | 197 | if single: 198 | currency_abbrevs = (single,) 199 | else: 200 | # A sample of names to start 201 | currency_abbrevs = ( 202 | # Official names 203 | "alt", "blessed", "chance", "chisel", "chrom", "divine", 204 | "jew", "regal", "regret", "scour", "vaal", 205 | # Names we saw in the data and adopted 206 | "c", "p", "mirror", "eshs-breachstone", "minotaur", 207 | "wisdom", 208 | # Names we got from poe.trade 209 | "fus", "alchemy", "gemc", "ex") 210 | 211 | db = self._get_default_db() 212 | cp = self._currency_postprocessor(db) 213 | 214 | for currency in currency_abbrevs: 215 | (amt, cur) = cp.parse_note("~price 1 " + currency) 216 | self.assertEqual(amt, 1) 217 | self.assertNotEqual(cur, currency) 218 | if should_be: 219 | self.assertEqual(cur, should_be) 220 | 221 | if single: 222 | return 223 | 224 | # Now bulk-test all presets 225 | from poefixer.postprocess.currency_names import \ 226 | OFFICIAL_CURRENCIES, UNOFFICIAL_CURRENCIES 227 | 228 | currencies = {} 229 | currencies.update(OFFICIAL_CURRENCIES) 230 | currencies.update(UNOFFICIAL_CURRENCIES) 231 | 232 | for abbrev, full in currencies.items(): 233 | price_note = "~b/o 1/2 " + abbrev 234 | (amt, cur) = cp.parse_note(price_note) 235 | self.assertEqual( 236 | cur, full, 237 | "Parse %s failed: %r != %r" % (price_note, cur, full)) 238 | self.assertEqual(amt, 1.0/2) 239 | 240 | def test_exalt_regression(self): 241 | self.test_currency_abbreviations( 242 | single='exalt', should_be='Exalted Orb') 243 | 244 | def test_trans_regression(self): 245 | self.test_currency_abbreviations( 246 | single='trans', should_be='Orb of Transmutation') 247 | 248 | def test_anull_regression(self): 249 | """Common spelling error""" 250 | 251 | self.test_currency_abbreviations( 252 | single='orb-of-anullment', should_be='Orb of Annulment') 253 | 254 | 255 | def sample_stashes(descriptors): 256 | 257 | stash = { 258 | 'id': '%064x' % 123456, 259 | 'accountName': 'JoeTest', 260 | 'stash': 'Goodies', 261 | 'stashType': 'X', 262 | 'public': True, 263 | 'league': 'Standard', 264 | 'items': []} 265 | 266 | offset = 0 267 | for desc in descriptors: 268 | (from_c, to_c, price) = desc 269 | stash['items'].append(currency_item(from_c, offset, to_c, price)) 270 | offset += 1 271 | 272 | return [poefixer.ApiStash(stash)] 273 | 274 | def currency_item(currency, offset, ask_currency, ask_value): 275 | """ 276 | Return a datastructure as if from the API for the given currency. 277 | 278 | Parameters: 279 | 280 | * `currency` - The name of the currency (e.g. "Chaos Orb") 281 | * `offset` - The id offset for this item. 282 | * `ask_currency` - The abbreviated currency in the price (e.g. "exa") 283 | * `ask_price` - The numeric quantity in the asking price (e.g. 1) 284 | """ 285 | 286 | return { 287 | # Boilerplate fields: 288 | 'w': 2, 'h': 1, 'x': 1, 'y': 1, 'ilvl': 1, 'league': 'Standard', 289 | 'frameType': 'X', 'icon': 'X', 'identified': True, 'verified': True, 290 | # Currency-specific info: 291 | 'id': '%064x' % offset, 'name': '', 'typeLine': currency, 292 | 'note': '~price %s %s' % (ask_value, ask_currency), 293 | 'category': {'currency': []}} 294 | 295 | 296 | if __name__ == '__main__': 297 | unittest.main() 298 | 299 | # vim: et:sts=4:sw=4:ai: 300 | -------------------------------------------------------------------------------- /poefixer/stashapi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | The core classes used by the PoE API python interface. To jump right in, see 5 | the `PoeApi` class. 6 | """ 7 | 8 | 9 | import re 10 | import time 11 | import logging 12 | import datetime 13 | import requests 14 | import requests.packages.urllib3.util.retry as urllib_retry 15 | import requests.adapters as requests_adapters 16 | import rapidjson as json 17 | 18 | 19 | __author__ = "Aaron Sherman " 20 | __copyright__ = "Copyright 2018, Aaron Sherman" 21 | __license__ = "MIT" 22 | __version__ = "1.0.0" 23 | __maintainer__ = "Aaron Sherman" 24 | __email__ = "ajs@ajs.com" 25 | 26 | 27 | POE_STASH_API_ENDPOINT = 'http://www.pathofexile.com/api/public-stash-tabs' 28 | 29 | 30 | # TODO: Move this out into something more central 31 | def requests_context(): 32 | session = requests.Session() 33 | retry = urllib_retry.Retry( 34 | total=10, 35 | backoff_factor=1, 36 | status_forcelist=(500, 502, 503, 504)) 37 | adapter = requests_adapters.HTTPAdapter(max_retries=retry) 38 | 39 | session.mount('http://', adapter) 40 | session.mount('https://', adapter) 41 | 42 | return session 43 | 44 | # Our abstract base creates its public methods dynamically 45 | # pylint: disable=too-few-public-methods 46 | class PoeApiData: 47 | """ 48 | A base class for data that is similar to a namedtuple, but for which 49 | individual property behaviors can be overwritten 50 | 51 | To use, simply subclass and set the "fields" attribute to the list of 52 | fields in the data structure. If you write your own property, simply 53 | access the underlying datastructure via `self._data` which, at the 54 | top-level is a dict of field names and values. 55 | 56 | Example: 57 | 58 | class Thing(PoeApiData): 59 | fields = ['field1', 'field2'] 60 | 61 | @property 62 | def field3(self): 63 | return self.field1 + self.field2 64 | 65 | """ 66 | 67 | fields = None 68 | required_fields = None 69 | 70 | def __init_subclass__(cls): 71 | def data_getter(name): 72 | """Because python doesn't have real closures""" 73 | # Because we don't want to conflict with auto-generate names: 74 | # pylint: disable=protected-access 75 | return property(lambda self: self._data.get(name, None)) 76 | 77 | super().__init_subclass__() 78 | assert cls.fields, "Incorrectly initialized PoeApiData class" 79 | added = [] 80 | # pylint doesn't know that we just validated that fields 81 | # has been overridden. 82 | # pylint: disable=not-an-iterable 83 | for field in cls.fields: 84 | if field.startswith('_'): 85 | raise KeyError("Invalid field name: %s" % field) 86 | if not hasattr(cls, field): 87 | added += [field] 88 | setattr(cls, field, data_getter(field)) 89 | 90 | def __init__(self, data, logger=logging): 91 | self._data = data 92 | self._logger = logger 93 | 94 | def _repr_fields(self): 95 | def format_fields(): 96 | for field in sorted(self.fields): 97 | value = getattr(self, field) 98 | if value is None: 99 | # Skip empty values for summary 100 | continue 101 | elif isinstance(value, str) and value.startswith('http'): 102 | if len(value) > 10: 103 | value = value[0:7] + '...' 104 | yield "%s=%r" % (field, value) 105 | return ", ".join(format_fields()) 106 | 107 | def __repr__(self): 108 | if self.fields: 109 | return "<%s(%s)>" % (self.__class__.__name__, self._repr_fields()) 110 | else: 111 | return "<%s()>" % self.__class__.__name__ 112 | 113 | def validate(self): 114 | """ 115 | Basic validation based on self.required_fields if present. 116 | 117 | Subclasses should implement their own validate as apporopriate 118 | and call `super().validate()` 119 | """ 120 | 121 | if self.required_fields: 122 | for field in self.required_fields: 123 | value = self._data.get(field, None) 124 | if value is None: 125 | raise ValueError( 126 | "%s: %s is a required field" % ( 127 | self.__class__.__name__, field)) 128 | 129 | 130 | class ApiItem(PoeApiData): 131 | """This is the core PoE item structure""" 132 | 133 | name_cleaner_re = re.compile(r'^\<\<.*\>\>') 134 | fields = [ 135 | "abyssJewel", "additionalProperties", "artFilename", 136 | "category", "corrupted", "cosmeticMods", "craftedMods", 137 | "descrText", "duplicated", "elder", "enchantMods", 138 | "explicitMods", "flavourText", "frameType", "h", "icon", 139 | "id", "identified", "ilvl", "implicitMods", "inventoryId", 140 | "isRelic", "league", "lockedToCharacter", "maxStackSize", "name", 141 | "nextLevelRequirements", "note", "properties", "prophecyDiffText", 142 | "prophecyText", "requirements", "secDescrText", "shaper", 143 | "socketedItems", "sockets", "stackSize", "support", 144 | "talismanTier", "typeLine", "utilityMods", "verified", "w", "x", 145 | "y"] 146 | 147 | required_fields = [ 148 | "category", "id", "h", "w", "x", "y", "frameType", "icon", 149 | "identified", "ilvl", "league", "name", "typeLine", "verified"] 150 | 151 | def _clean_markup(self, value): 152 | return re.sub(self.name_cleaner_re, '', value) 153 | 154 | # These names are given to us by the API, and are not python-aware. 155 | # pylint: disable=invalid-name 156 | @property 157 | def typeLine(self): 158 | """The type of the item. Markup is stripped.""" 159 | 160 | return self._clean_markup(self._data['typeLine']) 161 | 162 | @property 163 | def name(self): 164 | """The basic name of the item. Markup is stripped.""" 165 | 166 | return self._clean_markup(self._data['name']) 167 | 168 | 169 | class ApiStash(PoeApiData): 170 | """A stash aka "stash tab" is a collection of items in an x/y grid""" 171 | 172 | fields = [ 173 | 'accountName', 'lastCharacterName', 'id', 'stash', 'stashType', 174 | 'items', 'public'] 175 | 176 | required_fields = ['id', 'stashType', 'public'] 177 | 178 | @property 179 | def items(self): 180 | """The array of items (as a generator of ApiItem objects)""" 181 | 182 | for item in self._data['items']: 183 | api_item = ApiItem(item) 184 | try: 185 | api_item.validate() 186 | except ValueError as e: 187 | self._logger.warning("Invalid item: %s", str(e)) 188 | continue 189 | yield api_item 190 | 191 | @property 192 | def api_item_count(self): 193 | return len(self._data['items']) 194 | 195 | 196 | class PoeApi: 197 | """ 198 | This is the core API class. To access the PoE API, simply instantiate 199 | this class and call its "get_next" method as many times as you want to 200 | get a generator of stashes. 201 | 202 | Example: 203 | 204 | api = PoeApi() 205 | while True: 206 | for stash in api.get_next(): 207 | # do something with stash such as: 208 | for item in stash.items: 209 | # do something with the item such as: 210 | print(stash.name, ", ", stash.typeLine) 211 | 212 | Optional instantiation parameters: 213 | 214 | * `next_id` - The id of the first result to be fetched (internal to 215 | the HTTP API. 216 | * `rate` - The number of seconds (float) to wait between requests. 217 | Defaults to 1.1. Changing this can result in server-side rate- 218 | limiting. 219 | * `slow` - Be extra careful about issuing requests too fast by updating the 220 | last request counter AFTER a request completes. Otherwise, the 221 | counter is only updated BEFORE each request. 222 | * `api_root` - The PoE stash API root. Generally don't change this unless 223 | you have a mock server you use for testing. 224 | """ 225 | 226 | api_root = POE_STASH_API_ENDPOINT 227 | next_id = None 228 | rate = 1.1 229 | slow = False 230 | 231 | def __init__( 232 | self, 233 | next_id=None, rate=None, slow=None, api_root=None, logger=logging): 234 | self.logger = logger 235 | self.next_id = next_id 236 | if rate is not None: 237 | self.rate = datetime.timedelta(seconds=rate) 238 | if slow is not None: 239 | self.slow = slow 240 | if api_root is not None: 241 | self.api_root = api_root 242 | self.last_time = None 243 | self.rq_context = requests_context() 244 | 245 | def rate_wait(self): 246 | """Pause for the rest of the time left in our rate limiting parameter""" 247 | 248 | if self.last_time: 249 | now = datetime.datetime.now() 250 | delta = now - self.last_time 251 | if delta.total_seconds() < self.rate: 252 | remaining = self.rate - delta.total_seconds() 253 | time.sleep(remaining) 254 | self.set_last_time() 255 | 256 | def set_last_time(self): 257 | """Set the time of the last request for rate limiting""" 258 | 259 | self.last_time = datetime.datetime.now() 260 | 261 | def get_next(self): 262 | """Return the next stash generator""" 263 | 264 | self.rate_wait() 265 | data, self.next_id = self._get_data(next_id=self.next_id, slow=self.slow) 266 | return self.stash_generator(data) 267 | 268 | @staticmethod 269 | def stash_generator(data): 270 | """Turn a data blob from the API into a generator of ApiStash objects""" 271 | 272 | for stash in data: 273 | api_stash = ApiStash(stash) 274 | try: 275 | api_stash.validate() 276 | except ValueError as e: 277 | self.logger.warning("Invalid stash: %s", str(e)) 278 | continue 279 | yield api_stash 280 | 281 | def _get_data(self, next_id=None, slow=False): 282 | """Actually read from the API via requests library""" 283 | 284 | url = self.api_root 285 | if next_id: 286 | self.logger.info("Requesting next stash set: %s" % next_id) 287 | url += '?id=' + next_id 288 | else: 289 | self.logger.info("Requesting first stash set") 290 | req = self.rq_context.get(url) 291 | if slow: 292 | self.set_last_time() 293 | req.raise_for_status() 294 | self.logger.debug("Acquired stash data") 295 | # rapidjson doesn't tell python what its methods are... 296 | # pylint: disable=c-extension-no-member 297 | data = json.loads(req.text) 298 | self.logger.debug("Loaded stash data from JSON") 299 | if 'next_change_id' not in data: 300 | raise KeyError('next_change_id required field not present in response') 301 | return (data['stashes'], data['next_change_id']) 302 | 303 | if __name__ == '__main__': 304 | # For testing only... 305 | api = PoeApi() 306 | stashes = api.get_next() 307 | print("got first set of stashes") 308 | stashes = api.get_next() 309 | print("Next_id is %s" % api.next_id) 310 | done = False 311 | for input_stash in stashes: 312 | for stashitem in input_stash.items: 313 | print( 314 | "stash contains item: %s %s" % (stashitem.name, stashitem.typeLine)) 315 | done = True 316 | break 317 | if done: 318 | break 319 | 320 | 321 | # vim: sw=4 sts=4 et ai: 322 | -------------------------------------------------------------------------------- /poefixer/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | A database module for Python PoE API data. 3 | 4 | Where possible the names of the fields in the API are preserved. However, 5 | one major exception is in the "id" field, which is renamed to "api_id" 6 | and a new, auto-incrementing primary key is labeled "id". 7 | """ 8 | 9 | 10 | import re 11 | import time 12 | import logging 13 | import sqlalchemy 14 | from sqlalchemy.ext.declarative import declarative_base 15 | import rapidjson as json 16 | 17 | PoeDbBase = declarative_base() 18 | PoeDbMetadata = PoeDbBase.metadata 19 | 20 | # We're not doing a full implementation, here... 21 | # pylint: disable=abstract-method 22 | class SemiJSON(sqlalchemy.types.TypeDecorator): 23 | """A stopgap for using SQLite implementations that do not support JSON""" 24 | 25 | impl = sqlalchemy.UnicodeText 26 | 27 | def load_dialect_impl(self, dialect): 28 | if dialect.name == 'sqlite': 29 | return dialect.type_descriptor(self.impl) 30 | return dialect.type_descriptor(sqlalchemy.JSON()) 31 | 32 | # rapidjson doesn't appear to let python know that it has a dumps 33 | # function, so we have to give pylint a heads-up 34 | # pylint: disable=c-extension-no-member 35 | def process_bind_param(self, value, dialect): 36 | if dialect.name == 'sqlite' and value is not None: 37 | value = json.dumps(value) 38 | return value 39 | 40 | def process_result_value(self, value, dialect): 41 | if dialect.name == 'sqlite' and value is not None: 42 | value = json.loads(value) 43 | return value 44 | 45 | # SQLAlchemy table definitions do not need methods. 46 | # 47 | # pylint: disable=too-few-public-methods 48 | class Stash(PoeDbBase): 49 | """ 50 | The db-table for API stash data 51 | """ 52 | 53 | __tablename__ = 'stash' 54 | 55 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) 56 | api_id = sqlalchemy.Column( 57 | sqlalchemy.String(255), nullable=False, index=True, unique=True) 58 | accountName = sqlalchemy.Column(sqlalchemy.Unicode(255)) 59 | lastCharacterName = sqlalchemy.Column(sqlalchemy.Unicode(255)) 60 | stash = sqlalchemy.Column(sqlalchemy.Unicode(255)) 61 | stashType = sqlalchemy.Column(sqlalchemy.Unicode(32), nullable=False) 62 | public = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, index=True) 63 | created_at = sqlalchemy.Column( 64 | sqlalchemy.Integer, nullable=False, index=True) 65 | updated_at = sqlalchemy.Column( 66 | sqlalchemy.Integer, nullable=False, index=True) 67 | 68 | def __repr__(self): 69 | return "" % ( 70 | self.stash, self.id, self.api_id) 71 | 72 | 73 | class Item(PoeDbBase): 74 | """ 75 | The db-table for API item data 76 | """ 77 | 78 | __tablename__ = 'item' 79 | 80 | 81 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) 82 | api_id = sqlalchemy.Column( 83 | sqlalchemy.String(255), nullable=False, index=True, unique=True) 84 | stash_id = sqlalchemy.Column( 85 | sqlalchemy.Integer, sqlalchemy.ForeignKey("stash.id"), 86 | nullable=False) 87 | h = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) 88 | w = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) 89 | x = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) 90 | y = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) 91 | abyssJewel = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 92 | artFilename = sqlalchemy.Column(sqlalchemy.String(255)) 93 | # Note: API docs say this cannot be null, but we get null values 94 | category = sqlalchemy.Column(SemiJSON) 95 | corrupted = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 96 | cosmeticMods = sqlalchemy.Column(SemiJSON) 97 | craftedMods = sqlalchemy.Column(SemiJSON) 98 | descrText = sqlalchemy.Column(sqlalchemy.Unicode(255)) 99 | duplicated = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 100 | elder = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 101 | enchantMods = sqlalchemy.Column(SemiJSON) 102 | explicitMods = sqlalchemy.Column(SemiJSON) 103 | flavourText = sqlalchemy.Column(SemiJSON) 104 | frameType = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) 105 | icon = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) 106 | identified = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False) 107 | ilvl = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) 108 | implicitMods = sqlalchemy.Column(SemiJSON) 109 | inventoryId = sqlalchemy.Column(sqlalchemy.String(255)) 110 | isRelic = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 111 | league = sqlalchemy.Column( 112 | sqlalchemy.Unicode(64), nullable=False, index=True) 113 | lockedToCharacter = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 114 | maxStackSize = sqlalchemy.Column(sqlalchemy.Integer) 115 | name = sqlalchemy.Column( 116 | sqlalchemy.Unicode(255), nullable=False, index=True) 117 | nextLevelRequirements = sqlalchemy.Column(SemiJSON) 118 | note = sqlalchemy.Column(sqlalchemy.Unicode(255)) 119 | properties = sqlalchemy.Column(SemiJSON) 120 | prophecyDiffText = sqlalchemy.Column(sqlalchemy.Unicode(255)) 121 | prophecyText = sqlalchemy.Column(sqlalchemy.Unicode(255)) 122 | requirements = sqlalchemy.Column(SemiJSON) 123 | secDescrText = sqlalchemy.Column(sqlalchemy.Text) 124 | shaper = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 125 | sockets = sqlalchemy.Column(SemiJSON) 126 | stackSize = sqlalchemy.Column(sqlalchemy.Integer) 127 | support = sqlalchemy.Column(sqlalchemy.Boolean, default=False) 128 | talismanTier = sqlalchemy.Column(sqlalchemy.Integer) 129 | typeLine = sqlalchemy.Column( 130 | sqlalchemy.String(255), nullable=False, index=True) 131 | utilityMods = sqlalchemy.Column(SemiJSON) 132 | verified = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False) 133 | # This is an internal field which we use to track stash updates. 134 | # When a new version of the stash shows up, we mark all of the 135 | # items in it inactive, then we re-activate the one's we see again. 136 | active = sqlalchemy.Column( 137 | sqlalchemy.Boolean, nullable=False, default=True, index=True) 138 | created_at = sqlalchemy.Column( 139 | sqlalchemy.Integer, nullable=False, index=True) 140 | updated_at = sqlalchemy.Column( 141 | sqlalchemy.Integer, nullable=False, index=True) 142 | 143 | 144 | def __repr__(self): 145 | return "" % ( 146 | self.name, self.id, self.api_id, self.typeLine) 147 | 148 | 149 | class Sale(PoeDbBase): 150 | """ 151 | The digested sales data for each item. 152 | """ 153 | 154 | __tablename__ = 'sale' 155 | 156 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) 157 | item_id = sqlalchemy.Column( 158 | sqlalchemy.Integer, sqlalchemy.ForeignKey("item.id"), nullable=False) 159 | item_api_id = sqlalchemy.Column( 160 | sqlalchemy.String(255), nullable=False, index=True, unique=True) 161 | name = sqlalchemy.Column( 162 | sqlalchemy.Unicode(255), nullable=False, index=True) 163 | is_currency = sqlalchemy.Column( 164 | sqlalchemy.Boolean, nullable=False, index=True) 165 | sale_currency = sqlalchemy.Column( 166 | sqlalchemy.Unicode(255), nullable=False, index=True) 167 | sale_amount = sqlalchemy.Column(sqlalchemy.Float) 168 | sale_amount_chaos = sqlalchemy.Column(sqlalchemy.Float) 169 | created_at = sqlalchemy.Column( 170 | sqlalchemy.Integer, nullable=False, index=True) 171 | # The updated_at field from Item, as of the time that this sale 172 | # record was last updated. 173 | item_updated_at = sqlalchemy.Column( 174 | sqlalchemy.Integer, nullable=False, index=True) 175 | updated_at = sqlalchemy.Column( 176 | sqlalchemy.Integer, nullable=False, index=True) 177 | 178 | def __repr__(self): 179 | return "" % ( 180 | self.id, self.item_id, self.item_api_id) 181 | 182 | def __str__(self): 183 | """Summarize the sale for general consumption""" 184 | 185 | return ( 186 | "Sale(%s) ItemId=%s ItemApiId=%s value=%s " 187 | "Chaos=%s Time=%s") % ( 188 | self.id, self.item_id, self.item_api_id, 189 | self.sale_amount + " " + self.sale_currency, 190 | self.sale_amount_chaos) 191 | 192 | 193 | class CurrencySummary(PoeDbBase): 194 | __tablename__ = 'currency_summary' 195 | 196 | 197 | id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) 198 | from_currency = sqlalchemy.Column( 199 | sqlalchemy.Unicode(255), nullable=False) 200 | to_currency = sqlalchemy.Column( 201 | sqlalchemy.Unicode(255), nullable=False, index=True) 202 | league = sqlalchemy.Column(sqlalchemy.Unicode(64), nullable=False) 203 | count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) 204 | # This is the calculate weight based on the age and number of samples 205 | weight = sqlalchemy.Column(sqlalchemy.Float, nullable=False) 206 | mean = sqlalchemy.Column(sqlalchemy.Float, nullable=False) 207 | standard_dev = sqlalchemy.Column(sqlalchemy.Float, nullable=False) 208 | created_at = sqlalchemy.Column( 209 | sqlalchemy.Integer, nullable=False, index=True) 210 | updated_at = sqlalchemy.Column( 211 | sqlalchemy.Integer, nullable=False, index=True) 212 | 213 | __table_args__ = ( 214 | sqlalchemy.UniqueConstraint('from_currency', 'to_currency', 'league'),) 215 | 216 | 217 | class PoeDb: 218 | """ 219 | This is the wrapper for the item/stash database. All you need to 220 | do is instantiate it with an appropriate SQLAlchemy connection 221 | URI as the `db_connect` parameter (or default it to a sqlite db 222 | in "poetest.db") and send it stashes you get from the api like so: 223 | 224 | db = PoeDb() 225 | api = PoeApi() 226 | 227 | while True: 228 | for stash in api.get_next(): 229 | db.insert_api_stash(stash, with_items=True) 230 | 231 | """ 232 | 233 | db_connect = 'sqlite:///poetest.db' 234 | _safe_uri_re = re.compile(r'(?<=\:)([^:]*?)(?=\@)') 235 | _session = None 236 | _engine = None 237 | _session_maker = None 238 | 239 | stash_simple_fields = [ 240 | "accountName", "lastCharacterName", "stash", "stashType", 241 | "public"] 242 | item_simple_fields = [ 243 | "h", "w", "x", "y", "abyssJewel", "artFilename", 244 | "category", "corrupted", "cosmeticMods", "craftedMods", 245 | "descrText", "duplicated", "elder", "enchantMods", 246 | "explicitMods", "flavourText", "frameType", "icon", 247 | "identified", "ilvl", "implicitMods", "inventoryId", 248 | "isRelic", "league", "lockedToCharacter", "maxStackSize", 249 | "name", "nextLevelRequirements", "note", "properties", 250 | "prophecyDiffText", "prophecyText", "requirements", 251 | "secDescrText", "shaper", "sockets", 252 | "stackSize", "support", "talismanTier", "typeLine", 253 | "utilityMods", "verified"] 254 | 255 | def insert_api_stash(self, stash, with_items=False, keep_items=False): 256 | """ 257 | Given a PoeApi.ApiStash, insert its data into the Item table 258 | 259 | An optional `with_items` boolean may be set to true in order 260 | to recurse into the items in the given stash and insert/update 261 | them as well. The `keep_items` flag tells the insert to not 262 | mark all items associated with this insert as inactive. 263 | """ 264 | 265 | dbstash = self._insert_or_update_row( 266 | Stash, stash, self.stash_simple_fields) 267 | 268 | if with_items: 269 | # For now, it seems stashes are immutable anyway 270 | #if not keep_items: 271 | # self._invalidate_stash_items(dbstash) 272 | self.session.flush() 273 | self.session.refresh(dbstash) 274 | self.logger.debug( 275 | "Injecting %s items for stash: %s", 276 | stash.api_item_count, stash.id) 277 | for item in stash.items: 278 | self._insert_or_update_row( 279 | Item, item, self.item_simple_fields, stash=dbstash) 280 | 281 | def _invalidate_stash_items(self, dbstash): 282 | """Mark all items in this stash as inactive, pending update""" 283 | 284 | update = sqlalchemy.sql.expression.update(Item) 285 | update = update.where(Item.stash_id == dbstash.id) 286 | update = update.values(active=False) 287 | self.session.execute(update) 288 | 289 | def _insert_or_update_row(self, table, thing, simple_fields, stash=None): 290 | now = int(time.time()) 291 | query = self.session.query(table) 292 | if thing.id: 293 | existing = query.filter(table.api_id == thing.id).one_or_none() 294 | else: 295 | existing = None 296 | if existing: 297 | row = existing 298 | else: 299 | row = table() 300 | row.created_at = now 301 | 302 | row.api_id = thing.id 303 | row.updated_at = now 304 | if stash: 305 | row.stash_id = stash.id 306 | if table == Item: 307 | row.active = True 308 | 309 | for field in simple_fields: 310 | setattr(row, field, getattr(thing, field, None)) 311 | 312 | self.session.add(row) 313 | return row 314 | 315 | @property 316 | def session(self): 317 | """The current database context object""" 318 | 319 | if not self._session: 320 | self._session = self._session_maker() 321 | return self._session 322 | 323 | def create_database(self): 324 | """Write a new database from our schema""" 325 | 326 | PoeDbBase.metadata.create_all(self._engine) 327 | 328 | def _safe_uri(self, uri): 329 | return self._safe_uri_re.sub('******', uri) 330 | 331 | def __init__(self, db_connect=None, echo=False, logger=logging): 332 | self.logger=logger 333 | 334 | if db_connect is not None: 335 | self.logger.debug("Connect URI: %s", self._safe_uri(db_connect)) 336 | self.db_connect = db_connect 337 | 338 | self._engine = sqlalchemy.create_engine(self.db_connect, echo=echo) 339 | self._session_maker = sqlalchemy.orm.sessionmaker(bind=self._engine) 340 | 341 | 342 | # vim: sw=4 sts=4 et ai: 343 | -------------------------------------------------------------------------------- /poefixer/postprocess/currency.py: -------------------------------------------------------------------------------- 1 | """ 2 | Back-end for currency price postprocessing. 3 | 4 | The CurrencyPostprocessor class knows everything about tranforming 5 | data in the stash and item tables as seen from the API into our 6 | sales data and currency summaries. 7 | """ 8 | 9 | 10 | import time 11 | import math 12 | import numpy 13 | import logging 14 | import datetime 15 | 16 | import sqlalchemy 17 | 18 | import poefixer 19 | from .currency_names import \ 20 | PRICE_RE, PRICE_WITH_SPACE_RE, \ 21 | OFFICIAL_CURRENCIES, UNOFFICIAL_CURRENCIES 22 | 23 | 24 | class CurrencyPostprocessor: 25 | """ 26 | Take the sales and stash tables that represent very nearly as-is 27 | data from the API and start to crunch it down into some aggregates 28 | that represent the economy. This code is primarily responsible 29 | for tending the sale and currency_summary tables. 30 | """ 31 | 32 | db = None 33 | start_time = None 34 | logger = None 35 | limit = None 36 | actual_currencies = {} 37 | # How long can we go considering an existing calculation "close enough" 38 | # This is a performance tuning parameter. Intger number of mintues 39 | recent = None 40 | # Cutoff for considering "old" data 41 | relevant = int(datetime.timedelta(days=15).total_seconds()) 42 | # Weight the data we do consider based on an increment of a half-day 43 | weight_increment = int(datetime.timedelta(hours=12).total_seconds()) 44 | 45 | def __init__(self, db, start_time, 46 | continuous=False, 47 | recent=600, # Number of seconds, timedelta or None for caching 48 | limit=None, # Max number of rows to process 49 | logger=logging): 50 | self.db = db 51 | self.start_time = start_time 52 | self.continuous = continuous 53 | self.limit = limit 54 | self.logger = logger 55 | if recent is None or isinstance(recent, int): 56 | self.recent = recent 57 | elif isinstance(recent, datetime.timedelta): 58 | self.recent = recent.total_seconds() 59 | else: 60 | try: 61 | self.recent = int(recent) 62 | except: 63 | self.log("Invalid 'recent' caching parameter: %r", recent) 64 | raise 65 | 66 | def get_actual_currencies(self): 67 | """Get the currencies in the DB and create abbreviation mappings""" 68 | 69 | def get_full_names(): 70 | query = self.db.session.query(poefixer.CurrencySummary) 71 | query = query.add_columns(poefixer.CurrencySummary.from_currency) 72 | query = query.distinct() 73 | 74 | for row in query.all(): 75 | yield row.from_currency 76 | 77 | def dashed(name): 78 | return name.replace(' ', '-') 79 | 80 | def dashed_clean(name): 81 | return dashed(name).replace("'", "") 82 | 83 | full_names = list(get_full_names()) 84 | low = lambda name: name.lower() 85 | mapping = dict((low(name), name) for name in full_names) 86 | mapping.update( 87 | dict((dashed(low(name)), name) for name in full_names)) 88 | mapping.update( 89 | dict((dashed_clean(low(name)), name) for name in full_names)) 90 | 91 | self.logger.debug("Mapping of currencies: %r", mapping) 92 | 93 | return mapping 94 | 95 | def parse_note(self, note, regex=None): 96 | """ 97 | The 'note' is a user-edited field that sets pricing on an item or 98 | whole stash tab. 99 | 100 | Our goal is to parse out the sale price, if any, and return it or 101 | to returm None if there was no valid price. 102 | """ 103 | 104 | if note is not None: 105 | match = (regex or PRICE_RE).search(note) 106 | if match: 107 | try: 108 | (sale_type, amt, currency) = match.groups() 109 | low_cur = currency.lower() 110 | if '/' in amt: 111 | num, den = amt.split('/', 1) 112 | amt = float(num) / float(den) 113 | else: 114 | amt = float(amt) 115 | if low_cur in OFFICIAL_CURRENCIES: 116 | return (amt, OFFICIAL_CURRENCIES[low_cur]) 117 | elif low_cur in UNOFFICIAL_CURRENCIES: 118 | return (amt, UNOFFICIAL_CURRENCIES[low_cur]) 119 | elif low_cur in self.actual_currencies: 120 | return (amt, self.actual_currencies[low_cur]) 121 | elif currency: 122 | if regex is None: 123 | # Try with spaces and report the longer name 124 | # if present 125 | return self.parse_note( 126 | note, regex=PRICE_WITH_SPACE_RE) 127 | self.logger.warning( 128 | "Currency note: %r has unknown currency abbrev %s", 129 | note, currency) 130 | except ValueError as e: 131 | # If float() fails it raises ValueError 132 | if 'float' in str(e): 133 | self.logger.debug("Invalid price: %r" % note) 134 | else: 135 | raise 136 | return (None, None) 137 | 138 | def _currency_query(self, start, block_size, offset): 139 | """ 140 | Get a query from Item (linked to Stash) that have been updated since the 141 | last processed time given by `start`. 142 | 143 | Return a query that will fetch `block_size` rows starting at `offset`. 144 | """ 145 | 146 | Item = poefixer.Item 147 | 148 | query = self.db.session.query(poefixer.Item) 149 | query = query.join( 150 | poefixer.Stash, 151 | poefixer.Stash.id == poefixer.Item.stash_id) 152 | query = query.add_columns( 153 | poefixer.Item.id, 154 | poefixer.Item.api_id, 155 | poefixer.Item.typeLine, 156 | poefixer.Item.note, 157 | poefixer.Item.updated_at, 158 | poefixer.Stash.stash, 159 | poefixer.Item.name, 160 | poefixer.Stash.public) 161 | # Not currently in use 162 | #query = query.filter(poefixer.Item.active == True) 163 | query = query.filter(poefixer.Stash.public == True) 164 | #query = query.filter(sqlalchemy.func.json_contains_path( 165 | # poefixer.Item.category, 'all', '$.currency') == 1) 166 | 167 | if start is not None: 168 | query = query.filter(poefixer.Item.updated_at >= start) 169 | 170 | # Tried streaming, but the result is just too large for that. 171 | query = query.order_by( 172 | Item.updated_at, Item.created_at, Item.id).limit(block_size) 173 | if offset: 174 | query = query.offset(offset) 175 | 176 | return query 177 | 178 | def _update_currency_pricing( 179 | self, name, currency, league, price, sale_time, is_currency): 180 | """ 181 | Given a currency sale, update our understanding of what currency 182 | is now worth, and return the value of the sale in Chaos Orbs. 183 | """ 184 | 185 | if is_currency: 186 | self._update_currency_summary( 187 | name, currency, league, price, sale_time) 188 | 189 | return self.find_value_of(currency, league, price) 190 | 191 | def _get_mean_and_std(self, name, currency, league, sale_time): 192 | """ 193 | For a given currency sale, get the weighted mean and standard deviation. 194 | 195 | Full returned value list is: 196 | 197 | * mean 198 | * standard deviation 199 | * total of all weights used 200 | * count of considered rows 201 | 202 | This used to be done in the DB, but doing math in the database is 203 | a pain, and not very portable. Numpy lets us be pretty efficient, 204 | so we're not losing all that much. 205 | """ 206 | 207 | def calc_mean_std(values, weights): 208 | mean = numpy.average(values, weights=weights) 209 | variance = numpy.average((values-mean)**2, weights=weights) 210 | stddev = math.sqrt(variance) 211 | 212 | return (mean, stddev) 213 | 214 | now = int(time.time()) 215 | 216 | # This may be DB-specific. Eventually getting it into a 217 | # pure-SQLAlchemy form would be good... 218 | query = self.db.session.query(poefixer.Sale) 219 | query = query.join( 220 | poefixer.Item, poefixer.Sale.item_id == poefixer.Item.id) 221 | query = query.filter(poefixer.Sale.name == name) 222 | query = query.filter(poefixer.Item.league == league) 223 | query = query.filter(poefixer.Sale.sale_currency == currency) 224 | # Items older than a month are really not worth anything in terms 225 | # establishing the behavior of the economy. Even rare items like 226 | # mirrors move fast enough for a month to be sufficient. 227 | query = query.filter( 228 | poefixer.Sale.item_updated_at > (now-self.relevant)) 229 | query = query.add_columns( 230 | poefixer.Sale.sale_amount, 231 | poefixer.Sale.item_updated_at) 232 | 233 | values = numpy.array([( 234 | row.sale_amount, 235 | self.weight_increment/max(1,sale_time-row.item_updated_at)) 236 | for row in query.all()]) 237 | if len(values) == 0: 238 | return (None, None, None, None) 239 | prices = values[:,0] 240 | weights = values[:,1] 241 | mean, stddev = calc_mean_std(prices, weights) 242 | count = len(prices) 243 | total_weight = weights.sum() 244 | 245 | if count > 3 and stddev > mean/2: 246 | self.logger.debug( 247 | "%s->%s: Large stddev=%s vs mean=%s, recalibrating", 248 | name, currency, stddev, mean) 249 | # Throw out values outside of 2 stddev and try again 250 | prices_ok = numpy.absolute(prices-mean) <= stddev*2 251 | prices = numpy.extract(prices_ok, prices) 252 | weights = numpy.extract(prices_ok, weights) 253 | mean, stddev = calc_mean_std(prices, weights) 254 | count2 = len(prices) 255 | total_weight = weights.sum() 256 | self.logger.debug( 257 | "Recalibration ignored %s rows, final stddev=%s, mean=%s", 258 | count - count2, stddev, mean) 259 | count = count2 260 | 261 | return (float(mean), float(stddev), float(total_weight), count) 262 | 263 | def _update_currency_summary( 264 | self, name, currency, league, price, sale_time): 265 | """Update the currency summary table with this new price""" 266 | 267 | query = self.db.session.query(poefixer.CurrencySummary) 268 | query = query.filter(poefixer.CurrencySummary.from_currency == name) 269 | query = query.filter(poefixer.CurrencySummary.to_currency == currency) 270 | query = query.filter(poefixer.CurrencySummary.league == league) 271 | existing = query.one_or_none() 272 | 273 | now = int(time.time()) 274 | 275 | if ( 276 | self.recent and 277 | existing and 278 | existing.count >= 10 and 279 | existing and existing.updated_at >= now-self.recent): 280 | self.logger.debug( 281 | "Skipping cached currency: %s->%s %s(%s)", 282 | name, currency, league, price) 283 | return 284 | 285 | weighted_mean, weighted_stddev, weight, count = \ 286 | self._get_mean_and_std(name, currency, league, sale_time) 287 | 288 | self.logger.debug( 289 | "Weighted stddev of sale of %s in %s = %s", 290 | name, currency, weighted_stddev) 291 | if weighted_stddev is None: 292 | return None 293 | 294 | if existing: 295 | cmd = sqlalchemy.sql.expression.update(poefixer.CurrencySummary) 296 | cmd = cmd.where( 297 | poefixer.CurrencySummary.from_currency == name) 298 | cmd = cmd.where( 299 | poefixer.CurrencySummary.to_currency == currency) 300 | cmd = cmd.where( 301 | poefixer.CurrencySummary.league == league) 302 | add_values = {} 303 | else: 304 | cmd = sqlalchemy.sql.expression.insert(poefixer.CurrencySummary) 305 | add_values = { 306 | 'from_currency': name, 307 | 'to_currency': currency, 308 | 'league': league, 309 | 'created_at': int(time.time())} 310 | cmd = cmd.values( 311 | count=count, 312 | mean=weighted_mean, 313 | weight=weight, 314 | standard_dev=weighted_stddev, 315 | updated_at=int(time.time()), **add_values) 316 | self.db.session.execute(cmd) 317 | 318 | def find_value_of(self, name, league, price): 319 | """ 320 | Return the best current understanding of the value of the 321 | named currency, in chaos, in the given `league`, 322 | multiplied by the numeric `price`. 323 | 324 | Our primitive way of doing this for now is to say that the 325 | highest weighted conversion wins, presuming that that means 326 | the most stable sample, and we only try to follow the exchange 327 | to two levels down. Thus, we look for `X -> chaos` and 328 | `X -> Y -> chaos` and take whichever of those has the 329 | highest weighted sales (the weight of sales of 330 | `X -> Y -> chaos` being `min(weight(X->Y), weight(Y->chaos))` 331 | 332 | If all of that fails, we look for transactions going the other 333 | way (`chaos -> X`). This is less reliable, since it's a 334 | supply vs. demand side order, but if it's all we have, we 335 | roll with it. 336 | """ 337 | 338 | if name == 'Chaos Orb': 339 | # The value of a chaos orb is always 1 chaos orb 340 | return price 341 | 342 | from_currency_field = poefixer.CurrencySummary.from_currency 343 | to_currency_field = poefixer.CurrencySummary.to_currency 344 | league_field = poefixer.CurrencySummary.league 345 | 346 | query = self.db.session.query(poefixer.CurrencySummary) 347 | query = query.filter(from_currency_field == name) 348 | query = query.filter(league_field == league) 349 | query = query.order_by(poefixer.CurrencySummary.weight.desc()) 350 | high_score = None 351 | conversion = None 352 | for row in query.all(): 353 | target = row.to_currency 354 | if target == 'Chaos Orb': 355 | if not high_score or row.weight >= high_score: 356 | self.logger.debug( 357 | "Conversion discovered %s -> Chaos = %s", 358 | name, row.mean) 359 | high_score = row.weight 360 | conversion = row.mean 361 | break 362 | if high_score and row.weight <= high_score: 363 | # Can't get better than the high score 364 | continue 365 | 366 | query2 = self.db.session.query(poefixer.CurrencySummary) 367 | query2 = query2.filter(from_currency_field == target) 368 | query2 = query2.filter(to_currency_field == 'Chaos Orb') 369 | query2 = query2.filter(league_field == league) 370 | row2 = query2.one_or_none() 371 | if row2: 372 | score = min(row.weight, row2.weight) 373 | if (not high_score) or score > high_score: 374 | high_score = score 375 | conversion = row.mean * row2.mean 376 | self.logger.debug( 377 | "Conversion discovered %s -> %s (%s) -> Chaos (%s) = %s", 378 | name, target, row.mean, row2.mean, conversion) 379 | 380 | if high_score: 381 | return conversion * price 382 | else: 383 | query = self.db.session.query(poefixer.CurrencySummary) 384 | query = query.filter(from_currency_field == 'Chaos Orb') 385 | query = query.filter(to_currency_field == name) 386 | query = query.filter(league_field == league) 387 | row = query.one_or_none() 388 | 389 | if row: 390 | inverse = 1.0/row.mean 391 | if row: 392 | self.logger.debug( 393 | "Falling back on inverse Chaos -> %s pricing: %s", 394 | name, inverse) 395 | return inverse * price 396 | 397 | return None 398 | 399 | def _process_sale(self, row): 400 | if not ( 401 | (row.Item.note and row.Item.note.startswith('~')) or 402 | row.stash.startswith('~')): 403 | # No sale 404 | return None 405 | is_currency = 'currency' in row.Item.category 406 | if is_currency: 407 | name = row.Item.typeLine 408 | else: 409 | name = (row.Item.name + " " + row.Item.typeLine).strip() 410 | pricing = row.Item.note 411 | stash_pricing = row.stash 412 | stash_price, stash_currency = self.parse_note(stash_pricing) 413 | price, currency = self.parse_note(pricing) 414 | if price is None: 415 | # No item price, so fall back to stash 416 | price, currency = (stash_price, stash_currency) 417 | if price is None or price == 0: 418 | # No sale 419 | return None 420 | # We used to summarize each sale, but this can be a fairly 421 | # tight loop, so TODO: make this conditional on debug logging. 422 | #self.logger.debug( 423 | # "%s%s for sale for %s %s" % ( 424 | # name, 425 | # ("(currency) " if is_currency else ""), 426 | # price, currency)) 427 | existing = self.db.session.query(poefixer.Sale).filter( 428 | poefixer.Sale.item_id == row.Item.id).one_or_none() 429 | 430 | if not existing: 431 | existing = poefixer.Sale( 432 | item_id=row.Item.id, 433 | item_api_id=row.Item.api_id, 434 | name=name, 435 | is_currency=is_currency, 436 | sale_currency=currency, 437 | sale_amount=price, 438 | sale_amount_chaos=None, 439 | created_at=int(time.time()), 440 | item_updated_at=row.Item.updated_at, 441 | updated_at=int(time.time())) 442 | else: 443 | existing.sale_currency = currency 444 | existing.sale_amount = price 445 | existing.sale_amount_chaos = None 446 | existing.item_updated_at = row.Item.updated_at 447 | existing.updated_at = int(time.time()) 448 | 449 | # Add it so we can re-calc values... 450 | self.db.session.add(existing) 451 | 452 | league = row.Item.league 453 | 454 | amount_chaos = self._update_currency_pricing( 455 | name, currency, league, price, row.Item.updated_at, is_currency) 456 | 457 | if amount_chaos is not None: 458 | self.logger.debug( 459 | "Found chaos value of %s -> %s %s = %s", 460 | name, price, currency, amount_chaos) 461 | 462 | existing.sale_amount_chaos = amount_chaos 463 | self.db.session.merge(existing) 464 | 465 | return existing.id 466 | 467 | def get_last_processed_time(self): 468 | """ 469 | Get the item update time relevant to the most recent sale 470 | record. 471 | """ 472 | 473 | query = self.db.session.query(poefixer.Sale) 474 | query = query.order_by(poefixer.Sale.item_updated_at.desc()).limit(1) 475 | result = query.one_or_none() 476 | if result: 477 | reference_time = result.item_updated_at 478 | when = time.strftime( 479 | "%Y-%m-%d %H:%M:%S", 480 | time.localtime(reference_time)) 481 | self.logger.debug( 482 | "Last processed sale for item: %s(%s)", 483 | result.item_id, when) 484 | return reference_time 485 | return None 486 | 487 | 488 | def do_currency_postprocessor(self): 489 | """Process all of the currency data we've seen to date.""" 490 | 491 | def create_table(table, name): 492 | try: 493 | table.__table__.create(bind=self.db.session.bind) 494 | except (sqlalchemy.exc.OperationalError, 495 | sqlalchemy.exc.InternalError) as e: 496 | if 'already exists' not in str(e): 497 | raise 498 | self.logger.debug("%s table already exists.", name) 499 | else: 500 | self.logger.info("%s table created.", name) 501 | 502 | create_table(poefixer.Sale, "Sale") 503 | create_table(poefixer.CurrencySummary, "Currency Summary") 504 | 505 | prev = None 506 | while True: 507 | # Get all known currency names 508 | self.actual_currencies = self.get_actual_currencies() 509 | 510 | # Track what the most recently processed transaction was 511 | start = self.start_time or self.get_last_processed_time() 512 | if start: 513 | when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(start)) 514 | self.logger.info("Starting from %s", when) 515 | else: 516 | self.logger.info("Starting from beginning of item data.") 517 | 518 | # Actually process all outstading sale records 519 | (rows_done, last_row) = self._currency_processor_single_pass(start) 520 | 521 | # Pause if no processing was done 522 | if not prev or last_row != prev: 523 | prev = last_row 524 | self.logger.info("Processed %s rows in a pass", rows_done) 525 | elif self.continuous: 526 | time.sleep(1) 527 | 528 | if not self.continuous: 529 | break 530 | 531 | def _currency_processor_single_pass(self, start): 532 | 533 | offset = 0 534 | count = 0 535 | all_processed = 0 536 | todo = True 537 | block_size = 1000 # Number of rows per block 538 | last_row = None 539 | 540 | while todo: 541 | query = self._currency_query(start, block_size, offset) 542 | 543 | # Stashes are named with a conventional pricing descriptor and 544 | # items can have a note in the same format. The price of an item 545 | # is the item price with the stash price as a fallback. 546 | count = 0 547 | for row in query.all(): 548 | if not (row.Item.note or row.stash): 549 | continue 550 | max_id = row.Item.id 551 | count += 1 552 | self.logger.debug("Row in %s" % row.Item.id) 553 | if count % 1000 == 0: 554 | self.logger.info( 555 | "%s rows in... (%s)", 556 | count + offset, row.Item.updated_at) 557 | 558 | row_id = self._process_sale(row) 559 | 560 | if row_id: 561 | last_row = row_id 562 | 563 | todo = count == block_size 564 | offset += count 565 | self.db.session.commit() 566 | all_processed += count 567 | if self.limit and all_processed > self.limit: 568 | break 569 | 570 | return (all_processed, last_row) 571 | 572 | # vim: et:sw=4:sts=4:ai: 573 | -------------------------------------------------------------------------------- /poefixer/postprocess/currency_names.py: -------------------------------------------------------------------------------- 1 | """ 2 | Abbreviations for currencies in PoE 3 | """ 4 | 5 | import re 6 | 7 | 8 | # Currencies are abbreviated in notes. 9 | # The standard form of a note is this: 10 | PRICE_RE = re.compile(r'\~(price|b\/o)\s+(\S+)\s+([\w\-\']+)') 11 | PRICE_WITH_SPACE_RE = re.compile(r'\~(price|b\/o)\s+(\S+)\s+([ \w\-\']+)') 12 | 13 | # The last part of the note is an abbreviated currency name. 14 | # Official currencies are those available in the in-game drop-down menu 15 | OFFICIAL_CURRENCIES = { 16 | "alch": "Orb of Alchemy", 17 | "alt": "Orb of Alteration", 18 | "blessed": "Blessed Orb", 19 | "chance": "Orb of Chance", 20 | "chaos": "Chaos Orb", 21 | "chisel": "Cartographer's Chisel", 22 | "chrom": "Chromatic Orb", 23 | "divine": "Divine Orb", 24 | "exa": "Exalted Orb", 25 | "fuse": "Orb of Fusing", 26 | "gcp": "Gemcutter's Prism", 27 | "jew": "Jeweller's Orb", 28 | "regal": "Regal Orb", 29 | "regret": "Orb of Regret", 30 | "scour": "Orb of Scouring", 31 | "vaal": "Vaal Orb", 32 | } 33 | 34 | # But some players write in their own, and there are several common 35 | # ones in use. See http://currency.poe.trade/tags which I processed 36 | # using the script from scripts/currency_abbreviations.pl 37 | # Then we've added a few seen in the wild (first section) 38 | # Note that some of these appear in the official list, above as well 39 | UNOFFICIAL_CURRENCIES = { 40 | # Seen in the wild 41 | "c": "Chaos Orb", 42 | "p": "Perandus Coin", 43 | "pc": "Perandus Coin", 44 | "mirror": "Mirror of Kalandra", 45 | "fusing": "Orb of Fusing", 46 | "eshs-breachstone": "Esh's Breachstone", 47 | "esh-breachstone": "Esh's Breachstone", 48 | "xophs-breachstone": "Xoph's Breachstone", 49 | "xoph-breachstone": "Xoph's Breachstone", 50 | "chayulas-breachstone": "Chayula's Breachstone", 51 | "chayula-breachstone": "Chayula's Breachstone", 52 | "tuls-breachstone": "Tul's Breachstone", 53 | "tul-breachstone": "Tul's Breachstone", 54 | "uul-netols-breachstone": "Uul-Netol's Breachstone", 55 | "uul-netol-breachstone": "Uul-Netol's Breachstone", 56 | "minotaur": "Fragment of the Minotaur", 57 | "chimera": "Fragment of the Chimera", 58 | "phoenix": "Fragment of the Phoenix", 59 | "hydra": "Fragment of the Hydra", 60 | "wisdom": "Scroll of Wisdom", 61 | "the-samurais-eye": "The Samurai's Eye", 62 | "apprentice": "Apprentice Cartographer's Sextant", 63 | "journeyman": "Journeyman Cartographer's Sextant", 64 | "master": "Master Cartographer's Sextant", 65 | "exalt": "Exalted Orb", 66 | "trans": "Orb of Transmutation", 67 | "orb-of-transmutation": "Orb of Transmutation", 68 | "transmute": "Orb of Transmutation", 69 | "orb-of-anullment": "Orb of Annulment", 70 | # From http://currency.poe.trade/tags, see above 71 | "alt": "Orb of Alteration", 72 | "fuse": "Orb of Fusing", 73 | "fus": "Orb of Fusing", 74 | "alch": "Orb of Alchemy", 75 | "alchemy": "Orb of Alchemy", 76 | "chaos": "Chaos Orb", 77 | "choas": "Chaos Orb", 78 | "gemc": "Gemcutter's Prism", 79 | "gcp": "Gemcutter's Prism", 80 | "exa": "Exalted Orb", 81 | "ex": "Exalted Orb", 82 | "exalted": "Exalted Orb", 83 | "chrom": "Chromatic Orb", 84 | "chrome": "Chromatic Orb", 85 | "jew": "Jeweller's Orb", 86 | "chance": "Orb of Chance", 87 | "chanc": "Orb of Chance", 88 | "chisel": "Cartographer's Chisel", 89 | "cart": "Cartographer's Chisel", 90 | "scour": "Orb of Scouring", 91 | "blesse": "Blessed Orb", 92 | "regret": "Orb of Regret", 93 | "regr": "Orb of Regret", 94 | "regal": "Regal Orb", 95 | "rega": "Regal Orb", 96 | "divine": "Divine Orb", 97 | "div": "Divine Orb", 98 | "vaal": "Vaal Orb", 99 | "wis": "Scroll of Wisdom", 100 | "port": "Portal Scroll", 101 | "armour": "Armourer's Scrap", 102 | "blacksmith": "Blacksmith's Whetstone", 103 | "whetstone": "Blacksmith's Whetstone", 104 | "glass": "Glassblower's Bauble", 105 | "bauble": "Glassblower's Bauble", 106 | "tra": "Orb of Transmutation", 107 | "aug": "Orb of Augmentation", 108 | "mir": "Mirror of Kalandra", 109 | "kal": "Mirror of Kalandra", 110 | "ete": "Eternal Orb", 111 | "coin": "Perandus Coin", 112 | "coins": "Perandus Coin", 113 | "perandus": "Perandus Coin", 114 | "silver": "Silver Coin", 115 | "dusk": "Sacrifice at Dusk", 116 | "mid": "Sacrifice at Midnight", 117 | "dawn": "Sacrifice at Dawn", 118 | "noon": "Sacrifice at Noon", 119 | "grie": "Mortal Grief", 120 | "rage": "Mortal Rage", 121 | "hope": "Mortal Hope", 122 | "ign": "Mortal Ignorance", 123 | "eber": "Eber's Key", 124 | "yriel": "Yriel's Key", 125 | "inya": "Inya's Key", 126 | "volkuur": "Volkuur's Key", 127 | "offer": "Offering to the Goddess", 128 | "hydra": "Fragment of the Hydra", 129 | "phoenix": "Fragment of the Phoenix", 130 | "phenix": "Fragment of the Phoenix", 131 | "pheon": "Fragment of the Phoenix", 132 | "minot": "Fragment of the Minotaur", 133 | "chimer": "Fragment of the Chimera", 134 | "apprentice-sextant": "Apprentice Cartographer's Sextant", 135 | "journeyman-sextant": "Journeyman Cartographer's Sextant", 136 | "master-sextant": "Master Cartographer's Sextant", 137 | "sacrifice-set": "Sacrifice set", 138 | "mortal-set": "Mortal set", 139 | "pale-court-set": "Pale Court set", 140 | "shaper-set": "Shaper set", 141 | "splinter-xoph": "Splinter of Xoph", 142 | "splinter-of-xoph": "Splinter of Xoph", 143 | "splinter-tul": "Splinter of Tul", 144 | "splinter-of-tul": "Splinter of Tul", 145 | "splinter-esh": "Splinter of Esh", 146 | "splinter-of-esh": "Splinter of Esh", 147 | "splinter-uul-netol": "Splinter of Uul-Netol", 148 | "splinter-of-uul-netol": "Splinter of Uul-Netol", 149 | "splinter-chayula": "Splinter of Chayula", 150 | "splinter-of-chayula": "Splinter of Chayula", 151 | "blessing-xoph": "Blessing of Xoph", 152 | "blessing-of-xoph": "Blessing of Xoph", 153 | "blessing-tul": "Blessing of Tul", 154 | "blessing-of-tul": "Blessing of Tul", 155 | "blessing-esh": "Blessing of Esh", 156 | "blessing-of-esh": "Blessing of Esh", 157 | "blessing-uul-netol": "Blessing of Uul-Netol", 158 | "blessing-of-uul-netol": "Blessing of Uul-Netol", 159 | "blessing-chayula": "Blessing of Chayula", 160 | "blessing-of-chayula": "Blessing of Chayula", 161 | "xophs-breachstone": "Xoph's Breachstone", 162 | "tuls-breachstone": "Tul's Breachstone", 163 | "eshs-breachstone": "Esh's Breachstone", 164 | "uul-netol-breachstone": "Uul-Netol's Breachstone", 165 | "chayulas-breachstone": "Chayula's Breachstone", 166 | "essence-of-delirium": "Essence of Delirium", 167 | "essence-of-horror": "Essence of Horror", 168 | "essence-of-hysteria": "Essence of Hysteria", 169 | "essence-of-insanity": "Essence of Insanity", 170 | "screaming-essence-of-anger": "Screaming Essence of Anger", 171 | "shrieking-essence-of-anger": "Shrieking Essence of Anger", 172 | "deafening-essence-of-anger": "Deafening Essence of Anger", 173 | "screaming-essence-of-anguish": "Screaming Essence of Anguish", 174 | "shrieking-essence-of-anguish": "Shrieking Essence of Anguish", 175 | "deafening-essence-of-anguish": "Deafening Essence of Anguish", 176 | "screaming-essence-of-contempt": "Screaming Essence of Contempt", 177 | "shrieking-essence-of-contempt": "Shrieking Essence of Contempt", 178 | "deafening-essence-of-contempt": "Deafening Essence of Contempt", 179 | "screaming-essence-of-doubt": "Screaming Essence of Doubt", 180 | "shrieking-essence-of-doubt": "Shrieking Essence of Doubt", 181 | "deafening-essence-of-doubt": "Deafening Essence of Doubt", 182 | "screaming-essence-of-dread": "Screaming Essence of Dread", 183 | "shrieking-essence-of-dread": "Shrieking Essence of Dread", 184 | "deafening-essence-of-dread": "Deafening Essence of Dread", 185 | "screaming-essence-of-envy": "Screaming Essence of Envy", 186 | "shrieking-essence-of-envy": "Shrieking Essence of Envy", 187 | "deafening-essence-of-envy": "Deafening Essence of Envy", 188 | "screaming-essence-of-fear": "Screaming Essence of Fear", 189 | "shrieking-essence-of-fear": "Shrieking Essence of Fear", 190 | "deafening-essence-of-fear": "Deafening Essence of Fear", 191 | "screaming-essence-of-greed": "Screaming Essence of Greed", 192 | "shrieking-essence-of-greed": "Shrieking Essence of Greed", 193 | "deafening-essence-of-greed": "Deafening Essence of Greed", 194 | "screaming-essence-of-hatred": "Screaming Essence of Hatred", 195 | "shrieking-essence-of-hatred": "Shrieking Essence of Hatred", 196 | "deafening-essence-of-hatred": "Deafening Essence of Hatred", 197 | "screaming-essence-of-loathing": "Screaming Essence of Loathing", 198 | "shrieking-essence-of-loathing": "Shrieking Essence of Loathing", 199 | "deafening-essence-of-loathing": "Deafening Essence of Loathing", 200 | "screaming-essence-of-misery": "Screaming Essence of Misery", 201 | "shrieking-essence-of-misery": "Shrieking Essence of Misery", 202 | "deafening-essence-of-misery": "Deafening Essence of Misery", 203 | "screaming-essence-of-rage": "Screaming Essence of Rage", 204 | "shrieking-essence-of-rage": "Shrieking Essence of Rage", 205 | "deafening-essence-of-rage": "Deafening Essence of Rage", 206 | "screaming-essence-of-scorn": "Screaming Essence of Scorn", 207 | "shrieking-essence-of-scorn": "Shrieking Essence of Scorn", 208 | "deafening-essence-of-scorn": "Deafening Essence of Scorn", 209 | "screaming-essence-of-sorrow": "Screaming Essence of Sorrow", 210 | "shrieking-essence-of-sorrow": "Shrieking Essence of Sorrow", 211 | "deafening-essence-of-sorrow": "Deafening Essence of Sorrow", 212 | "screaming-essence-of-spite": "Screaming Essence of Spite", 213 | "shrieking-essence-of-spite": "Shrieking Essence of Spite", 214 | "deafening-essence-of-spite": "Deafening Essence of Spite", 215 | "screaming-essence-of-suffering": "Screaming Essence of Suffering", 216 | "shrieking-essence-of-suffering": "Shrieking Essence of Suffering", 217 | "deafening-essence-of-suffering": "Deafening Essence of Suffering", 218 | "screaming-essence-of-torment": "Screaming Essence of Torment", 219 | "shrieking-essence-of-torment": "Shrieking Essence of Torment", 220 | "deafening-essence-of-torment": "Deafening Essence of Torment", 221 | "screaming-essence-of-woe": "Screaming Essence of Woe", 222 | "shrieking-essence-of-woe": "Shrieking Essence of Woe", 223 | "deafening-essence-of-woe": "Deafening Essence of Woe", 224 | "screaming-essence-of-wrath": "Screaming Essence of Wrath", 225 | "shrieking-essence-of-wrath": "Shrieking Essence of Wrath", 226 | "deafening-essence-of-wrath": "Deafening Essence of Wrath", 227 | "screaming-essence-of-zeal": "Screaming Essence of Zeal", 228 | "shrieking-essence-of-zeal": "Shrieking Essence of Zeal", 229 | "deafening-essence-of-zeal": "Deafening Essence of Zeal", 230 | "remnant-of-corruption": "Remnant of Corruption", 231 | "a-mothers-parting-gift": "A Mother's Parting Gift", 232 | "abandoned-wealth": "Abandoned Wealth", 233 | "anarchys-price": "Anarchy's Price", 234 | "assassins-favour": "Assassin's Favour", 235 | "atziris-arsenal": "Atziri's Arsenal", 236 | "audacity": "Audacity", 237 | "birth-of-the-three": "Birth of the Three", 238 | "blind-venture": "Blind Venture", 239 | "boundless-realms": "Boundless Realms", 240 | "bowyers-dream": "Bowyer's Dream", 241 | "call-to-the-first-ones": "Call to the First Ones", 242 | "cartographers-delight": "Cartographer's Delight", 243 | "chaotic-disposition": "Chaotic Disposition", 244 | "coveted-possession": "Coveted Possession", 245 | "death": "Death", 246 | "destined-to-crumble": "Destined to Crumble", 247 | "diallas-subjugation": "Dialla's Subjugation", 248 | "doedres-madness": "Doedre's Madness", 249 | "dying-anguish": "Dying Anguish", 250 | "earth-drinker": "Earth Drinker", 251 | "emperor-of-purity": "Emperor of Purity", 252 | "emperors-luck": "Emperor's Luck", 253 | "gemcutters-promise": "Gemcutter's Promise", 254 | "gift-of-the-gemling-queen": "Gift of the Gemling Queen", 255 | "glimmer-of-hope": "Glimmer of Hope", 256 | "grave-knowledge": "Grave Knowledge", 257 | "her-mask": "Her Mask", 258 | "heterochromia": "Heterochromia", 259 | "hope-card": "Hope", 260 | "house-of-mirrors": "House of Mirrors", 261 | "hubris": "Hubris", 262 | "humility": "Humility", 263 | "hunters-resolve": "Hunter's Resolve", 264 | "hunters-reward": "Hunter's Reward", 265 | "jack-in-the-box": "Jack in the Box", 266 | "lantadors-lost-love": "Lantador's Lost Love", 267 | "last-hope": "Last Hope", 268 | "light-and-truth": "Light and Truth", 269 | "lingering-remnants": "Lingering Remnants", 270 | "lost-worlds": "Lost Worlds", 271 | "loyalty": "Loyalty", 272 | "lucky-connections": "Lucky Connections", 273 | "lucky-deck": "Lucky Deck", 274 | "lysahs-respite": "Lysah's Respite", 275 | "mawr-blaidd": "Mawr Blaidd", 276 | "merciless-armament": "Merciless Armament", 277 | "might-is-right": "Might is Right", 278 | "mitts": "Mitts", 279 | "pride-before-the-fall": "Pride Before the Fall", 280 | "prosperity": "Prosperity", 281 | "rain-tempter": "Rain Tempter", 282 | "rain-of-chaos": "Rain of Chaos", 283 | "rats": "Rats", 284 | "scholar-of-the-seas": "Scholar of the Seas", 285 | "shard-of-fate": "Shard of Fate", 286 | "struck-by-lightning": "Struck by Lightning", 287 | "the-aesthete": "The Aesthete", 288 | "the-arena-champion": "The Arena Champion", 289 | "the-artist": "The Artist", 290 | "the-avenger": "The Avenger", 291 | "the-battle-born": "The Battle Born", 292 | "the-betrayal": "The Betrayal", 293 | "the-body": "The Body", 294 | "the-brittle-emperor": "The Brittle Emperor", 295 | "the-calling": "The Calling", 296 | "the-carrion-crow": "The Carrion Crow", 297 | "the-cartographer": "The Cartographer", 298 | "the-cataclysm": "The Cataclysm", 299 | "the-catalyst": "The Catalyst", 300 | "the-celestial-justicar": "The Celestial Justicar", 301 | "the-chains-that-bind": "The Chains that Bind", 302 | "the-coming-storm": "The Coming Storm", 303 | "the-conduit": "The Conduit", 304 | "the-cursed-king": "The Cursed King", 305 | "the-dapper-prodigy": "The Dapper Prodigy", 306 | "the-dark-mage": "The Dark Mage", 307 | "the-demoness": "The Demoness", 308 | "the-devastator": "The Devastator", 309 | "the-doctor": "The Doctor", 310 | "the-doppelganger": "The Doppelganger", 311 | "the-dragon": "The Dragon", 312 | "the-dragons-heart": "The Dragon's Heart", 313 | "the-drunken-aristocrat": "The Drunken Aristocrat", 314 | "the-encroaching-darkness": "The Encroaching Darkness", 315 | "the-endurance": "The Endurance", 316 | "the-enlightened": "The Enlightened", 317 | "the-ethereal": "The Ethereal", 318 | "the-explorer": "The Explorer", 319 | "the-feast": "The Feast", 320 | "the-fiend": "The Fiend", 321 | "the-fletcher": "The Fletcher", 322 | "the-floras-gift": "The Flora's Gift", 323 | "the-formless-sea": "The Formless Sea", 324 | "the-forsaken": "The Forsaken", 325 | "the-fox": "The Fox", 326 | "the-gambler": "The Gambler", 327 | "the-garish-power": "The Garish Power", 328 | "the-gemcutter": "The Gemcutter", 329 | "the-gentleman": "The Gentleman", 330 | "the-gladiator": "The Gladiator", 331 | "the-harvester": "The Harvester", 332 | "the-hermit": "The Hermit", 333 | "the-hoarder": "The Hoarder", 334 | "the-hunger": "The Hunger", 335 | "the-immortal": "The Immortal", 336 | "the-incantation": "The Incantation", 337 | "the-inoculated": "The Inoculated", 338 | "the-inventor": "The Inventor", 339 | "the-jester": "The Jester", 340 | "the-kings-blade": "The King's Blade", 341 | "the-kings-heart": "The King's Heart", 342 | "the-last-one-standing": "The Last One Standing", 343 | "the-lich": "The Lich", 344 | "the-lion": "The Lion", 345 | "the-lord-in-black": "The Lord in Black", 346 | "the-lover": "The Lover", 347 | "the-lunaris-priestess": "The Lunaris Priestess", 348 | "the-mercenary": "The Mercenary", 349 | "the-metalsmiths-gift": "The Metalsmith's Gift", 350 | "the-oath": "The Oath", 351 | "the-offering": "The Offering", 352 | "the-one-with-all": "The One With All", 353 | "the-opulent": "The Opulent", 354 | "the-pack-leader": "The Pack Leader", 355 | "the-pact": "The Pact", 356 | "the-penitent": "The Penitent", 357 | "the-poet": "The Poet", 358 | "the-polymath": "The Polymath", 359 | "the-porcupine": "The Porcupine", 360 | "the-queen": "The Queen", 361 | "the-rabid-rhoa": "The Rabid Rhoa", 362 | "the-risk": "The Risk", 363 | "the-road-to-power": "The Road to Power", 364 | "the-saints-treasure": "The Saint's Treasure", 365 | "the-scarred-meadow": "The Scarred Meadow", 366 | "the-scavenger": "The Scavenger", 367 | "the-scholar": "The Scholar", 368 | "the-sephirot": "The Sephirot", 369 | "the-sigil": "The Sigil", 370 | "the-siren": "The Siren", 371 | "the-soul": "The Soul", 372 | "the-spark-and-the-flame": "The Spark and the Flame", 373 | "the-spoiled-prince": "The Spoiled Prince", 374 | "the-standoff": "The Standoff", 375 | "the-stormcaller": "The Stormcaller", 376 | "the-summoner": "The Summoner", 377 | "the-sun": "The Sun", 378 | "the-surgeon": "The Surgeon", 379 | "the-surveyor": "The Surveyor", 380 | "the-survivalist": "The Survivalist", 381 | "the-thaumaturgist": "The Thaumaturgist", 382 | "the-throne": "The Throne", 383 | "the-tower": "The Tower", 384 | "the-traitor": "The Traitor", 385 | "the-trial": "The Trial", 386 | "the-twins": "The Twins", 387 | "the-tyrant": "The Tyrant", 388 | "the-union": "The Union", 389 | "the-valkyrie": "The Valkyrie", 390 | "the-valley-of-steel-boxes": "The Valley of Steel Boxes", 391 | "the-vast": "The Vast", 392 | "the-visionary": "The Visionary", 393 | "the-void": "The Void", 394 | "the-warden": "The Warden", 395 | "the-warlord": "The Warlord", 396 | "the-watcher": "The Watcher", 397 | "the-web": "The Web", 398 | "the-wind": "The Wind", 399 | "the-wolf": "The Wolf", 400 | "the-wolfs-shadow": "The Wolf's Shadow", 401 | "the-wolven-kings-bite": "The Wolven King's Bite", 402 | "the-wolverine": "The Wolverine", 403 | "the-wrath": "The Wrath", 404 | "the-wretched": "The Wretched", 405 | "three-faces-in-the-dark": "Three Faces in the Dark", 406 | "thunderous-skies": "Thunderous Skies", 407 | "time-lost-relic": "Time-Lost Relic", 408 | "tranquillity": "Tranquillity", 409 | "treasure-hunter": "Treasure Hunter", 410 | "turn-the-other-cheek": "Turn the Other Cheek", 411 | "vinias-token": "Vinia's Token", 412 | "volatile-power": "Volatile Power", 413 | "wealth-and-power": "Wealth and Power", 414 | "abyss-map": "Abyss", 415 | "academy-map": "Academy", 416 | "acid-lakes-map": "Acid Lakes", 417 | "arachnid-nest-map": "Arachnid Nest", 418 | "arachnid-tomb-map": "Arachnid Tomb", 419 | "arcade-map": "Arcade", 420 | "arena-map": "Arena", 421 | "arid-lake-map": "Arid Lake", 422 | "armoury-map": "Armoury", 423 | "arsenal-map": "Arsenal", 424 | "ashen-wood-map": "Ashen Wood", 425 | "atoll-map": "Atoll", 426 | "barrows-map": "Barrows", 427 | "bazaar-map": "Bazaar", 428 | "beach-map": "Beach", 429 | "beacon-map": "Beacon", 430 | "bog-map": "Bog", 431 | "burial-chambers-map": "Burial Chambers", 432 | "canyon-map": "Canyon", 433 | "castle-ruins-map": "Castle Ruins", 434 | "catacombs-map": "Catacombs", 435 | "cavern-map": "Cavern", 436 | "cells-map": "Cells", 437 | "cemetery-map": "Cemetery", 438 | "channel-map": "Channel", 439 | "chateau-map": "Chateau", 440 | "colonnade-map": "Colonnade", 441 | "colosseum-map": "Colosseum", 442 | "core-map": "Core", 443 | "courtyard-map": "Courtyard", 444 | "coves-map": "Coves", 445 | "crematorium-map": "Crematorium", 446 | "crypt-map": "Crypt", 447 | "crystal-ore-map": "Crystal Ore", 448 | "dark-forest-map": "Dark Forest", 449 | "desert-map": "Desert", 450 | "dunes-map": "Dunes", 451 | "dungeon-map": "Dungeon", 452 | "estuary-map": "Estuary", 453 | "excavation-map": "Excavation", 454 | "factory-map": "Factory", 455 | "forge-of-the-phoenix-map": "Forge of the Phoenix", 456 | "ghetto-map": "Ghetto", 457 | "gorge-map": "Gorge", 458 | "graveyard-map": "Graveyard", 459 | "grotto-map": "Grotto", 460 | "high-gardens-map": "High Gardens", 461 | "ivory-temple-map": "Ivory Temple", 462 | "jungle-valley-map": "Jungle Valley", 463 | "lair-map": "Lair", 464 | "lair-of-the-hydra-map": "Lair of the Hydra", 465 | "malformation-map": "Malformation", 466 | "marshes-map": "Marshes", 467 | "maze-map": "Maze", 468 | "maze-of-the-minotaur-map": "Maze of the Minotaur", 469 | "mesa-map": "Mesa", 470 | "mineral-pools-map": "Mineral Pools", 471 | "mud-geyser-map": "Mud Geyser", 472 | "museum-map": "Museum", 473 | "necropolis-map": "Necropolis", 474 | "oasis-map": "Oasis", 475 | "orchard-map": "Orchard", 476 | "overgrown-ruin-map": "Overgrown Ruin", 477 | "overgrown-shrine-map": "Overgrown Shrine", 478 | "palace-map": "Palace", 479 | "peninsula-map": "Peninsula", 480 | "phantasmagoria-map": "Phantasmagoria", 481 | "pier-map": "Pier", 482 | "pit-map": "Pit", 483 | "pit-of-the-chimera-map": "Pit of the Chimera", 484 | "plateau-map": "Plateau", 485 | "plaza-map": "Plaza", 486 | "precinct-map": "Precinct", 487 | "primordial-pool-map": "Primordial Pool", 488 | "promenade-map": "Promenade", 489 | "quarry-map": "Quarry", 490 | "port-map": "Port", 491 | "racecourse-map": "Racecourse", 492 | "ramparts-map": "Ramparts", 493 | "reef-map": "Reef", 494 | "residence-map": "Residence", 495 | "scriptorium-map": "Scriptorium", 496 | "sewer-map": "Sewer", 497 | "shaped-academy-map": "Shaped Academy", 498 | "shaped-acid-lakes-map": "Shaped Acid Lakes", 499 | "shaped-arachnid-nest-map": "Shaped Arachnid Nest", 500 | "shaped-arachnid-tomb-map": "Shaped Arachnid Tomb", 501 | "shaped-arcade-map": "Shaped Arcade", 502 | "shaped-arena-map": "Shaped Arena", 503 | "shaped-arid-lake-map": "Shaped Arid Lake", 504 | "shaped-armoury-map": "Shaped Armoury", 505 | "shaped-arsenal-map": "Shaped Arsenal", 506 | "shaped-ashen-wood-map": "Shaped Ashen Wood", 507 | "shaped-atoll-map": "Shaped Atoll", 508 | "shaped-barrows-map": "Shaped Barrows", 509 | "shaped-beach-map": "Shaped Beach", 510 | "shaped-bog-map": "Shaped Bog", 511 | "shaped-burial-chambers-map": "Shaped Burial Chambers", 512 | "shaped-canyon-map": "Shaped Canyon", 513 | "shaped-castle-ruins-map": "Shaped Castle Ruins", 514 | "shaped-catacombs-map": "Shaped Catacombs", 515 | "shaped-cavern-map": "Shaped Cavern", 516 | "shaped-cells-map": "Shaped Cells", 517 | "shaped-cemetery-map": "Shaped Cemetery", 518 | "shaped-channel-map": "Shaped Channel", 519 | "shaped-colonnade-map": "Shaped Colonnade", 520 | "shaped-courtyard-map": "Shaped Courtyard", 521 | "shaped-coves-map": "Shaped Coves", 522 | "shaped-crypt-map": "Shaped Crypt", 523 | "shaped-crystal-ore-map": "Shaped Crystal Ore", 524 | "shaped-desert-map": "Shaped Desert", 525 | "shaped-dunes-map": "Shaped Dunes", 526 | "shaped-dungeon-map": "Shaped Dungeon", 527 | "shaped-factory-map": "Shaped Factory", 528 | "shaped-ghetto-map": "Shaped Ghetto", 529 | "shaped-graveyard-map": "Shaped Graveyard", 530 | "shaped-grotto-map": "Shaped Grotto", 531 | "shaped-jungle-valley-map": "Shaped Jungle Valley", 532 | "shaped-malformation-map": "Shaped Malformation", 533 | "shaped-marshes-map": "Shaped Marshes", 534 | "shaped-mesa-map": "Shaped Mesa", 535 | "shaped-mud-geyser-map": "Shaped Mud Geyser", 536 | "shaped-museum-map": "Shaped Museum", 537 | "shaped-oasis-map": "Shaped Oasis", 538 | "shaped-orchard-map": "Shaped Orchard", 539 | "shaped-overgrown-shrine-map": "Shaped Overgrown Shrine", 540 | "shaped-peninsula-map": "Shaped Peninsula", 541 | "shaped-phantasmagoria-map": "Shaped Phantasmagoria", 542 | "shaped-pier-map": "Shaped Pier", 543 | "shaped-pit-map": "Shaped Pit", 544 | "shaped-primordial-pool-map": "Shaped Primordial Pool", 545 | "shaped-promenade-map": "Shaped Promenade", 546 | "shaped-quarry-map": "Shaped Quarry", 547 | "shaped-port-map": "Shaped Port", 548 | "shaped-racecourse-map": "Shaped Racecourse", 549 | "shaped-ramparts-map": "Shaped Ramparts", 550 | "shaped-reef-map": "Shaped Reef", 551 | "shaped-toxic-sewer-map": "Shaped Toxic Sewer", 552 | "shaped-shore-map": "Shaped Shore", 553 | "shaped-spider-forest-map": "Shaped Spider Forest", 554 | "shaped-spider-lair-map": "Shaped Spider Lair", 555 | "shaped-strand-map": "Shaped Strand", 556 | "shaped-temple-map": "Shaped Temple", 557 | "shaped-terrace-map": "Shaped Terrace", 558 | "shaped-thicket-map": "Shaped Thicket", 559 | "shaped-tower-map": "Shaped Tower", 560 | "shaped-tropical-island-map": "Shaped Tropical Island", 561 | "shaped-underground-river-map": "Shaped Underground River", 562 | "shaped-vaal-city-map": "Shaped Vaal City", 563 | "shaped-vaal-pyramid-map": "Shaped Vaal Pyramid", 564 | "shaped-villa-map": "Shaped Villa", 565 | "shaped-waste-pool-map": "Shaped Waste Pool", 566 | "shaped-wharf-map": "Shaped Wharf", 567 | "shipyard-map": "Shipyard", 568 | "shore-map": "Shore", 569 | "shrine-map": "Shrine", 570 | "spider-forest-map": "Spider Forest", 571 | "spider-lair-map": "Spider Lair", 572 | "springs-map": "Springs", 573 | "strand-map": "Strand", 574 | "sulphur-wastes-map": "Sulphur Wastes", 575 | "temple-map": "Temple", 576 | "terrace-map": "Terrace", 577 | "thicket-map": "Thicket", 578 | "torture-chamber-map": "Torture Chamber", 579 | "tower-map": "Tower", 580 | "tropical-island-map": "Tropical Island", 581 | "underground-river-map": "Underground River", 582 | "underground-sea-map": "Underground Sea", 583 | "vaal-city-map": "Vaal City", 584 | "vaal-pyramid-map": "Vaal Pyramid", 585 | "vaal-temple-map": "Vaal Temple", 586 | "vault-map": "Vault", 587 | "villa-map": "Villa", 588 | "volcano-map": "Volcano", 589 | "waste-pool-map": "Waste Pool", 590 | "wasteland-map": "Wasteland", 591 | "waterways-map": "Waterways", 592 | "wharf-map": "Wharf", 593 | "ancient-reliquary-key": "Ancient Reliquary Key", 594 | "ambush-leaguestone": "Ambush", 595 | "anarchy-leaguestone": "Anarchy", 596 | "beyond-leaguestone": "Beyond", 597 | "bloodlines-leaguestone": "Bloodlines", 598 | "breach-leaguestone": "Breach", 599 | "domination-leaguestone": "Domination", 600 | "essence-leaguestone": "Essence", 601 | "invasion-leaguestone": "Invasion", 602 | "nemesis-leaguestone": "Nemesis", 603 | "onslaught-leaguestone": "Onslaught", 604 | "perandus-leaguestone": "Perandus", 605 | "prophecy-leaguestone": "Prophecy", 606 | "rampage-leaguestone": "Rampage", 607 | "talisman-leaguestone": "Talisman", 608 | "tempest-leaguestone": "Tempest", 609 | "torment-leaguestone": "Torment", 610 | "warbands-leaguestone": "Warbands", 611 | "divine-vessel": "Divine Vessel", 612 | "orb-of-annulment": "Orb of Annulment", 613 | "orb-of-binding": "Orb of Binding", 614 | "orb-of-horizons": "Orb of Horizons", 615 | "harbingers-orb": "Harbinger's Orb", 616 | "engineers-orb": "Engineer's Orb", 617 | "ancient-orb": "Ancient Orb", 618 | "annulment-shard": "Annulment Shard", 619 | "mirror-shard": "Mirror Shard", 620 | "exalted-shard": "Exalted Shard", 621 | "alleyways-map": "Alleyways", 622 | "ancient-city-map": "Ancient City", 623 | "basilica-map": "Basilica", 624 | "belfry-map": "Belfry", 625 | "bone-crypt-map": "Bone Crypt", 626 | "cage-map": "Cage", 627 | "caldera-map": "Caldera", 628 | "carcass-map": "Carcass", 629 | "city-square-map": "City Square", 630 | "conservatory-map": "Conservatory", 631 | "coral-ruins-map": "Coral Ruins", 632 | "courthouse-map": "Courthouse", 633 | "crimson-temple-map": "Crimson Temple", 634 | "cursed-crypt-map": "Cursed Crypt", 635 | "defiled-cathedral-map": "Defiled Cathedral", 636 | "desert-spring-map": "Desert Spring", 637 | "dig-map": "Dig", 638 | "fields-map": "Fields", 639 | "flooded-mine-map": "Flooded Mine", 640 | "gardens-map": "Gardens", 641 | "geode-map": "Geode", 642 | "harbinger-map": "Harbinger", 643 | "haunted-mansion-map": "Haunted Mansion", 644 | "iceberg-map": "Iceberg", 645 | "infested-valley-map": "Infested Valley", 646 | "laboratory-map": "Laboratory", 647 | "lava-chamber-map": "Lava Chamber", 648 | "lava-lake-map": "Lava Lake", 649 | "leyline-map": "Leyline", 650 | "lighthouse-map": "Lighthouse", 651 | "lookout-map": "Lookout", 652 | "mausoleum-map": "Mausoleum", 653 | "moon-temple-map": "Moon Temple", 654 | "park-map": "Park", 655 | "pen-map": "Pen", 656 | "relic-chambers-map": "Relic Chambers", 657 | "sepulchre-map": "Sepulchre", 658 | "siege-map": "Siege", 659 | "sulphur-vents-map": "Sulphur Vents", 660 | "summit-map": "Summit", 661 | "sunken-city-map": "Sunken City", 662 | "toxic-sewer-map": "Toxic Sewer", 663 | "tribunal-map": "Tribunal", 664 | "the-ruthless-ceinture": "The Ruthless Ceinture", 665 | "no-traces": "No Traces", 666 | "the-realm": "The Realm", 667 | "the-eye-of-the-dragon": "The Eye of the Dragon", 668 | "the-blazing-fire": "The Blazing Fire", 669 | "left-to-fate": "Left to Fate", 670 | "shaped-vault-map": "Shaped Vault", 671 | "shaped-bazaar-map": "Shaped Bazaar", 672 | "shaped-haunted-mansion-map": "Shaped Haunted Mansion", 673 | "shaped-infested-valley-map": "Shaped Infested Valley", 674 | "shaped-mausoleum-map": "Shaped Mausoleum", 675 | "shaped-lookout-map": "Shaped Lookout", 676 | "shaped-alleyways-map": "Shaped Alleyways", 677 | "shaped-pen-map": "Shaped Pen", 678 | "shaped-flooded-mine-map": "Shaped Flooded Mine", 679 | "shaped-iceberg-map": "Shaped Iceberg", 680 | "shaped-cage-map": "Shaped Cage", 681 | "shaped-springs-map": "Shaped Springs", 682 | "shaped-excavation-map": "Shaped Excavation", 683 | "shaped-leyline-map": "Shaped Leyline", 684 | "shaped-city-square-map": "Shaped City Square", 685 | "shaped-relic-chambers-map": "Shaped Relic Chambers", 686 | "shaped-courthouse-map": "Shaped Courthouse", 687 | "shaped-chateau-map": "Shaped Chateau", 688 | "shaped-gorge-map": "Shaped Gorge", 689 | "shaped-volcano-map": "Shaped Volcano", 690 | "shaped-lighthouse-map": "Shaped Lighthouse", 691 | "shaped-conservatory-map": "Shaped Conservatory", 692 | "shaped-sulphur-vents-map": "Shaped Sulphur Vents", 693 | "should-not-display": "should-not-display", 694 | "shaped-ancient-city-map": "Shaped Ancient City", 695 | "shaped-ivory-temple-map": "Shaped Ivory Temple", 696 | "shaped-fields-map": "Shaped Fields", 697 | "shaped-underground-sea-map": "Shaped Underground Sea", 698 | "shaped-tribunal-map": "Shaped Tribunal", 699 | "shaped-coral-ruins-map": "Shaped Coral Ruins", 700 | "shaped-lava-chamber-map": "Shaped Lava Chamber", 701 | "shaped-residence-map": "Shaped Residence", 702 | "shaped-bone-crypt-map": "Shaped Bone Crypt", 703 | "shaped-gardens-map": "Shaped Gardens", 704 | "shaped-laboratory-map": "Shaped Laboratory", 705 | "shaped-overgrown-ruin-map": "Shaped Overgrown Ruin", 706 | "shaped-geode-map": "Shaped Geode", 707 | "shaped-mineral-pools-map": "Shaped Mineral Pools", 708 | "shaped-moon-temple-map": "Shaped Moon Temple", 709 | "shaped-sepulchre-map": "Shaped Sepulchre", 710 | "shaped-plateau-map": "Shaped Plateau", 711 | "shaped-estuary-map": "Shaped Estuary", 712 | "shaped-scriptorium-map": "Shaped Scriptorium", 713 | "shaped-siege-map": "Shaped Siege", 714 | "shaped-shipyard-map": "Shaped Shipyard", 715 | "shaped-belfry-map": "Shaped Belfry", 716 | "shaped-wasteland-map": "Shaped Wasteland", 717 | "shaped-precinct-map": "Shaped Precinct", 718 | "shaped-cursed-crypt-map": "Shaped Cursed Crypt", 719 | "the-insatiable": "The Insatiable", 720 | "the-obscured": "The Obscured", 721 | "the-iron-bard": "The Iron Bard", 722 | "forbidden-power": "Forbidden Power", 723 | "the-breach": "The Breach", 724 | "the-dreamer": "The Dreamer", 725 | "the-world-eater": "The World Eater", 726 | "the-deceiver": "The Deceiver", 727 | "blessing-of-god": "Blessing of God", 728 | "the-puzzle": "The Puzzle", 729 | "bestiary-orb": "Bestiary Orb", 730 | "imprinted-bestiary-orb": "Imprinted Bestiary Orb", 731 | "simple-rope-net": "Simple Rope Net", 732 | "reinforced-rope-net": "Reinforced Rope Net", 733 | "strong-rope-net": "Strong Rope Net", 734 | "simple-iron-net": "Simple Iron Net", 735 | "reinforced-iron-net": "Reinforced Iron Net", 736 | "strong-iron-net": "Strong Iron Net", 737 | "simple-steel-net": "Simple Steel Net", 738 | "reinforced-steel-net": "Reinforced Steel Net", 739 | "strong-steel-net": "Strong Steel Net", 740 | "thaumaturgical-net": "Thaumaturgical Net", 741 | "necromancy-net": "Necromancy Net", 742 | "harbinger's-shard": "Harbinger's Shard", 743 | "vial-of-dominance": "Vial of Dominance", 744 | "vial-of-summoning": "Vial of Summoning", 745 | "vial-of-awakening": "Vial of Awakening", 746 | "vial-of-the-ritual": "Vial of the Ritual", 747 | "vial-of-fate": "Vial of Fate", 748 | "vial-of-consequence": "Vial of Consequence", 749 | "vial-of-the-ghost": "Vial of the Ghost", 750 | "vial-of-transcendence": "Vial of Transcendence", 751 | "vial-of-sacrifice": "Vial of Sacrifice", 752 | "harmony-of-souls": "Harmony of Souls", 753 | "immortal-resolve": "Immortal Resolve", 754 | "perfection": "Perfection", 755 | "the-admirer": "The Admirer", 756 | "the-army-of-blood": "The Army of Blood", 757 | "the-beast": "The Beast", 758 | "the-celestial-stone": "The Celestial Stone", 759 | "the-darkest-dream": "The Darkest Dream", 760 | "the-dreamland": "The Dreamland", 761 | "the-fathomless-depths": "The Fathomless Depths", 762 | "the-hale-heart": "The Hale Heart", 763 | "the-jeweller's-boon": "The Jeweller's Boon", 764 | "the-master": "The Master", 765 | "the-mayor": "The Mayor", 766 | "the-professor": "The Professor", 767 | "the-rite-of-elements": "The Rite of Elements", 768 | "the-samurai's-eye": "The Samurai's Eye", 769 | "the-sword-king's-salute": "The Sword King's Salute", 770 | "the-undaunted": "The Undaunted", 771 | "the-undisputed": "The Undisputed", 772 | "the-witch": "The Witch", 773 | "three-voices": "Three Voices", 774 | } 775 | 776 | # vim: et:sw=4:sts=4:ai: 777 | --------------------------------------------------------------------------------