/**
* Copyright (c) 2005-2017, KoLmafia development team
* http://kolmafia.sourceforge.net/
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* [1] Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* [2] Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in
* the documentation and/or other materials provided with the
* distribution.
* [3] Neither the name "KoLmafia" nor the names of its contributors may
* be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
* FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package net.sourceforge.kolmafia.request;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.sourceforge.kolmafia.AdventureResult;
import net.sourceforge.kolmafia.KoLCharacter;
import net.sourceforge.kolmafia.KoLmafia;
import net.sourceforge.kolmafia.persistence.CoinmastersDatabase;
import net.sourceforge.kolmafia.persistence.ItemDatabase;
import net.sourceforge.kolmafia.persistence.NPCStoreDatabase;
import net.sourceforge.kolmafia.session.StoreManager;
import net.sourceforge.kolmafia.utilities.StringUtilities;
public class MallSearchRequest
extends GenericRequest
{
private static final Pattern FAVORITES_PATTERN = Pattern.compile( "&action=unfave&whichstore=(\\d+)\">" );
private static final Pattern STOREID_PATTERN = Pattern.compile( "<b>(.*?) \\(<a.*?who=(\\d+)\"" );
private static final Pattern STORELIMIT_PATTERN = Pattern.compile( "Limit ([\\d,]+) /" );
private static final Pattern STOREPRICE_PATTERN =
Pattern.compile( "radio value=(\\d+).*?<b>(.*?)</b> \\(([\\d,]+)\\)(.*?)</td>" );
private static final Pattern ITEMDETAIL_PATTERN =
Pattern.compile( "<table class=\"itemtable\".*?item_(\\d+).*?descitem\\((\\d+)\\).*?<a[^>]*>(.*?)</a>(.*?)</table>" );
private static final Pattern STOREDETAIL_PATTERN =
Pattern.compile( "<tr class=\"graybelow.+?</tr>" );
private static final Pattern LISTQUANTITY_PATTERN = Pattern.compile( "stock\">([\\d,]+)<" );
private static final Pattern LISTLIMIT_PATTERN = Pattern.compile( "([\\d,]+)\\ \\/\\ day" );
private static final Pattern LISTDETAIL_PATTERN =
Pattern.compile( "whichstore=(\\d+)\\&searchitem=(\\d+)\\&searchprice=(\\d+)\"><b>(.*?)</b>" );
// (Items 1-10 of 45)
private static final Pattern ITERATION_PATTERN = Pattern.compile( "\\(Items (\\d+)-(\\d+) of (\\d+)\\)" );
private List<PurchaseRequest> results;
private final boolean retainAll;
private String searchString;
public MallSearchRequest( final int storeId )
{
super( "mallstore.php" );
this.addFormField( "whichstore", String.valueOf( storeId ) );
this.results = new ArrayList<PurchaseRequest>();
this.retainAll = true;
}
/**
* Constructs a new <code>MallSearchRequest</code> which searches for the given item, storing the results in the
* given <code>ListModel</code>. Note that the search string is exactly the same as the way KoL does it at the
* current time.
*
* @param searchString The string (including wildcards) for the item to be found
* @param cheapestCount The number of stores to show; use a non-positive number to show all
* @param results The list in which to store the results
* @param retainAll Whether the result list should be cleared before searching
*/
public MallSearchRequest( final String searchString, final int cheapestCount, final List<PurchaseRequest> results,
final boolean retainAll )
{
super( "mall.php" );
this.searchString = searchString;
this.addFormField( "pudnuggler", this.searchString );
this.addFormField( "category", "allitems" );
this.addFormField( "consumable_byme", "0" );
this.addFormField( "weaponattribute", "3" );
this.addFormField( "wearable_byme", "0" );
this.addFormField( "nolimits", "0" );
this.addFormField( "max_price", "0" );
this.addFormField( "sortresultsby", "price" );
this.addFormField( "justitems", "0" );
this.addFormField( "x_cheapest", String.valueOf( cheapestCount ) );
this.results = results;
this.retainAll = retainAll;
}
@Override
protected boolean retryOnTimeout()
{
return true;
}
public static final String getSearchString( String itemName )
{
int itemId = ItemDatabase.getItemId( itemName );
if ( itemId == -1 )
{
return itemName;
}
String dataName = ItemDatabase.getItemDataName( itemId );
int entityIndex = dataName.indexOf( "&" );
if ( entityIndex == -1 )
{
return dataName;
}
dataName = StringUtilities.globalStringReplace(
dataName, "<", "<" );
dataName = StringUtilities.globalStringReplace(
dataName, ">", ">" );
dataName = StringUtilities.globalStringReplace(
dataName, "&", "&" );
return dataName;
}
public List<PurchaseRequest> getResults()
{
return this.results;
}
public void setResults( final List<PurchaseRequest> results )
{
this.results = results;
}
/**
* Executes the search request. In the event that no item is found, the currently active frame will be notified.
* Otherwise, all items are stored inside of the results list. Note also that the results will be cleared before
* being stored.
*/
@Override
public void run()
{
boolean items;
if ( this.searchString == null || this.searchString.trim().length() == 0 )
{
KoLmafia.updateDisplay( this.retainAll ? "Scanning store inventories..." : "Looking up favorite stores list..." );
items = false;
}
else
{
// If only NPC items, no mall search needed
if ( !this.updateSearchString() )
{
return;
}
KoLmafia.updateDisplay( "Searching for " + this.searchString + "..." );
items = true;
}
// We may need to iterate over multiple pages of search results
this.removeFormField( "start" );
int page = 1;
int limit = 0;
while ( true )
{
if ( page > 1 )
{
KoLmafia.updateDisplay( "Searching for " + this.searchString + " (" + page + " of " + limit + ")..." );
}
super.run();
if ( !items )
{
break;
}
if ( this.responseText == null || !KoLmafia.permitsContinue() )
{
return;
}
Matcher matcher = MallSearchRequest.ITERATION_PATTERN.matcher( this.responseText );
if ( !matcher.find() )
{
break;
}
if ( limit == 0 )
{
int total = StringUtilities.parseInt( matcher.group(3) );
limit = ( total + 9 ) / 10;
}
if ( ++page > limit )
{
break;
}
int end = StringUtilities.parseInt( matcher.group(2) );
this.addFormField( "start", String.valueOf( end ) );
}
// If an exact match, we can think about updating mall_price().
if ( this.searchString != null && this.searchString.startsWith( "\"" ) && this.results.size() > 0 )
{
AdventureResult item = this.results.get(0).getItem();
StoreManager.maybeUpdateMallPrice( item, new ArrayList<PurchaseRequest>( this.results ) );
}
KoLmafia.updateDisplay( "Search complete." );
}
private boolean updateSearchString()
{
this.results.clear();
boolean exact = this.searchString.startsWith( "\"" ) && this.searchString.endsWith( "\"" );
// If the search string is enclosed in "", the Item Matcher
// will look for an exact match. Otherwise, it will do fuzzy
// matching.
List itemNames = ItemDatabase.getMatchingNames( this.searchString );
// Check for any items which are not available in NPC stores and
// known not to be tradeable to see if there's an exact match.
Iterator itemIterator = itemNames.iterator();
int npcItemCount = 0;
int untradeableCount = 0;
while ( itemIterator.hasNext() )
{
String itemName = (String) itemIterator.next();
int itemId = ItemDatabase.getItemId( itemName );
boolean untradeable = !ItemDatabase.isTradeable( itemId );
if ( NPCStoreDatabase.contains( itemId ) ||
CoinmastersDatabase.contains( itemId ) )
{
npcItemCount++;
if ( untradeable )
{
untradeableCount++;
}
}
else if ( untradeable )
{
itemIterator.remove();
}
}
int count = itemNames.size();
if ( count == 0 )
{
// Assume the user knows what they want and allow an
// unknown search for an exact match;
return exact;
}
// If the results contain only untradeable NPC items, then you
// don't need to run a mall search.
if ( count == untradeableCount )
{
this.finalizeList( itemNames );
return false;
}
// If there is only one applicable match, then search for the
// exact item (may be a fuzzy matched item).
if ( count == 1 )
{
if ( !exact )
{
this.searchString = "\"" + MallSearchRequest.getSearchString( (String) itemNames.get( 0 ) ) + "\"";
}
this.addFormField( "pudnuggler", this.searchString );
}
return true;
}
private void searchStore()
{
Pattern mangledEntityPattern = Pattern.compile( "\\s+;" );
if ( this.retainAll )
{
Matcher shopMatcher = MallSearchRequest.STOREID_PATTERN.matcher( this.responseText );
if ( !shopMatcher.find() )
{
return; // no mall store
}
int shopId = StringUtilities.parseInt( shopMatcher.group( 2 ) );
// Handle character entities mangled by KoL.
String shopName = new String( mangledEntityPattern.matcher( shopMatcher.group( 1 ) ).replaceAll( ";" ) );
int lastFindIndex = 0;
Matcher priceMatcher = MallSearchRequest.STOREPRICE_PATTERN.matcher( this.responseText );
while ( priceMatcher.find( lastFindIndex ) )
{
lastFindIndex = priceMatcher.end();
String priceId = priceMatcher.group( 1 );
String itemName = priceMatcher.group( 2 );
int itemId = StringUtilities.parseInt( priceId.substring( 0, priceId.length() - 9 ) );
int quantity = StringUtilities.parseInt( priceMatcher.group( 3 ) );
int limit = quantity;
Matcher limitMatcher = MallSearchRequest.STORELIMIT_PATTERN.matcher( priceMatcher.group( 4 ) );
if ( limitMatcher.find() )
{
limit = StringUtilities.parseInt( limitMatcher.group( 1 ) );
}
int price = StringUtilities.parseInt( priceId.substring( priceId.length() - 9 ) );
this.results.add( new MallPurchaseRequest(
itemId, quantity, shopId, shopName, price, limit, true ) );
}
}
else
{
MallSearchRequest individualStore;
Matcher storeMatcher = MallSearchRequest.FAVORITES_PATTERN.matcher( this.responseText );
int lastFindIndex = 0;
while ( storeMatcher.find( lastFindIndex ) )
{
lastFindIndex = storeMatcher.end();
individualStore = new MallSearchRequest( StringUtilities.parseInt( storeMatcher.group( 1 ) ) );
individualStore.run();
this.results.addAll( individualStore.results );
}
}
}
private void searchMall()
{
List itemNames = ItemDatabase.getMatchingNames( this.searchString );
// Change all multi-line store names into single line store
// names so that the parser doesn't get confused; remove all
// stores where limits have already been reached (which have
// been greyed out), and then remove all non-anchor tags to
// make everything easy to parse.
int startIndex = this.responseText.indexOf( "Search Results:" );
String storeListResult = this.responseText.substring( startIndex < 0 ? 0 : startIndex );
int previousItemId = -1;
Matcher itemMatcher = MallSearchRequest.ITEMDETAIL_PATTERN.matcher( storeListResult );
while ( itemMatcher.find() )
{
int itemId = StringUtilities.parseInt( itemMatcher.group(1) );
String itemName = itemMatcher.group(3);
if ( !itemName.equals( ItemDatabase.getItemDataName( itemId ) ) )
{
String descId = itemMatcher.group(2);
ItemDatabase.registerItem( itemId, itemName, descId );
}
String itemListResult = itemMatcher.group(4);
Matcher linkMatcher = MallSearchRequest.STOREDETAIL_PATTERN.matcher( itemListResult );
while ( linkMatcher.find() )
{
String linkText = linkMatcher.group();
Matcher quantityMatcher = MallSearchRequest.LISTQUANTITY_PATTERN.matcher( linkText );
int quantity = 0;
if ( quantityMatcher.find() )
{
quantity = StringUtilities.parseInt( quantityMatcher.group( 1 ) );
}
int limit = quantity;
boolean canPurchase = true;
Matcher limitMatcher = MallSearchRequest.LISTLIMIT_PATTERN.matcher( linkText );
if ( limitMatcher.find() )
{
limit = StringUtilities.parseInt( limitMatcher.group( 1 ) );
canPurchase = linkText.indexOf( "graybelow limited" ) == -1;
}
// The next token contains data which identifies the shop
// and the item (which will be used later), and the price!
// which means you don't need to consult the next token.
Matcher detailsMatcher = MallSearchRequest.LISTDETAIL_PATTERN.matcher( linkText );
if ( !detailsMatcher.find() )
{
continue;
}
int shopId = StringUtilities.parseInt( detailsMatcher.group( 1 ) );
// If we have tried to purchase from this store this session
// and discovered that it is disabled or ignoring you, skip it.
if ( MallPurchaseRequest.disabledStores.contains( shopId ) ||
MallPurchaseRequest.ignoringStores.contains( shopId ))
{
continue;
}
if ( previousItemId != itemId )
{
previousItemId = itemId;
this.addNPCStoreItem( itemId );
this.addCoinMasterItem( itemId );
itemNames.remove( itemName );
}
// Only add mall store results if the NPC store option
// is not available.
int price = StringUtilities.parseInt( detailsMatcher.group( 3 ) );
String shopName = new String( detailsMatcher.group( 4 ).replaceAll( "<br>", " " ) );
this.results.add( new MallPurchaseRequest( itemId, quantity, shopId, shopName, price, limit, canPurchase ) );
}
}
// Once the search is complete, add in any remaining NPC
// store data and finalize the list.
this.finalizeList( itemNames );
}
private void addNPCStoreItem( final int itemId )
{
if ( NPCStoreDatabase.contains( itemId, false ) )
{
PurchaseRequest item = NPCStoreDatabase.getPurchaseRequest( itemId );
if ( !this.results.contains( item ) )
{
this.results.add( item );
}
}
}
private void addCoinMasterItem( final int itemId )
{
PurchaseRequest item = CoinmastersDatabase.getPurchaseRequest( itemId );
if ( item != null )
{
if ( !this.results.contains( item ) )
{
this.results.add( item );
}
}
}
private void finalizeList( final List itemNames )
{
// Now, for the items which matched, check to see if there are
// any entries inside of the NPC store database for them and
// add - this is just in case some of the items become notrade
// so items can still be bought from the NPC stores.
for ( int i = 0; i < itemNames.size(); ++i )
{
String itemName = (String) itemNames.get( i );
int itemId = ItemDatabase.getItemId( itemName );
this.addNPCStoreItem( itemId );
this.addCoinMasterItem( itemId );
}
}
@Override
public void processResults()
{
if ( this.searchString == null || this.searchString.trim().length() == 0 )
{
this.searchStore();
return;
}
this.searchMall();
}
private static final Pattern NOBUYERS_PATTERN = Pattern.compile( "<td valign=\"center\" class=\"buyers\"> </td>" );
public static void decorateMallSearch( StringBuffer buffer )
{
Matcher matcher = MallSearchRequest.STOREDETAIL_PATTERN.matcher( buffer );
while ( matcher.find() )
{
String store = matcher.group( 0 );
Matcher nobuyersMatcher = MallSearchRequest.NOBUYERS_PATTERN.matcher( store );
if ( !nobuyersMatcher.find() )
{
// Good store which does not disable buying from search results
continue;
}
// Bad store which disables buying from the search results.
Matcher detailsMatcher = MallSearchRequest.LISTDETAIL_PATTERN.matcher( store );
if ( !detailsMatcher.find() )
{
continue;
}
String whichstore = detailsMatcher.group( 1 );
String searchitem = detailsMatcher.group( 2 );
int itemId = StringUtilities.parseInt( searchitem );
String searchprice = detailsMatcher.group( 3 );
int price = StringUtilities.parseInt( searchprice );
// Replace:
// <td valign="center" class="buyers"> </td>
// with:
// <td valign="center" class="buyers">[<a href="mallstore.php?buying=1&quantity=1&whichitem=3980000004455&ajax=1&pwd&whichstore=102069" class="buyone">buy</a>] [<a href="#" rel="mallstore.php?buying=1&whichitem=3980000004455&ajax=1&pwd&whichstore=102069&quantity=" class="buysome">buy some</a>]</td>
String storeString = MallPurchaseRequest.getStoreString( itemId, price );
StringBuilder buyers = new StringBuilder();
buyers.append( "<td valign=\"center\" class=\"buyers\">" );
buyers.append( "[<a href=\"mallstore.php?buying=1&quantity=1&whichitem=" );
buyers.append( storeString );
buyers.append( "&ajax=1&pwd=" );
buyers.append( GenericRequest.passwordHash );
buyers.append( "&whichstore=" );
buyers.append( whichstore );
buyers.append( "\" class=\"buyone\">buy</a>]" );
buyers.append( " " );
buyers.append( "[<a href=\"#\" rel =\"mallstore.php?buying=1&whichitem=" );
buyers.append( storeString );
buyers.append( "&ajax=1&pwd=" );
buyers.append( GenericRequest.passwordHash );
buyers.append( "&whichstore=" );
buyers.append( whichstore );
buyers.append( "&quantity=\" class=\"buysome\">buy some</a>]" );
buyers.append( "</td>" );
buffer.replace( matcher.start() + nobuyersMatcher.start(),
matcher.start() + nobuyersMatcher.end(),
buyers.toString() );
}
}
}