├── 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 |
--------------------------------------------------------------------------------
|