chiark / gitweb /
yoweb-scrape: remove erroneous exponential
[ypp-sc-tools.db-live.git] / yoweb-scrape
index e3a7c8c19c7b97f5e7c1e7dbb30f3418b101859a..cee5c1781aafbb9505f25647a8d10ed1f6fc10de 100755 (executable)
@@ -44,6 +44,8 @@ import random
 import curses
 import termios
 import random
+import subprocess
+import copy
 from optparse import OptionParser
 from StringIO import StringIO
 
@@ -98,22 +100,37 @@ def format_time_interval(ti):
        if ti < 86400: return '%dh' % (ti / 3600)
        return '%dd' % (ti / 86400)
 
+def yppsc_dir():
+       lib = os.getenv("YPPSC_YARRG_SRCBASE")
+       if lib is not None: return lib
+       lib = sys.argv[0] 
+       lib = regexp.sub('/[^/]+$', '', lib)
+       os.environ["YPPSC_YARRG_SRCBASE"] = lib
+       return lib
+
+soup_massage = copy.copy(BeautifulSoup.MARKUP_MASSAGE)
+soup_massage.append(
+               (regexp.compile('(\<td.*") ("center")'),
+                lambda m: m.group(1)+' align='+m.group(2))
+       )
+
+def make_soup(*args, **kwargs):
+       return BeautifulSoup(*args,
+               convertEntities=BeautifulSoup.HTML_ENTITIES,
+               markupMassage=soup_massage,
+                        **kwargs)
+
 #---------- caching and rate-limiting data fetcher ----------
 
 class Fetcher:
-       def __init__(self, ocean, cachedir):
+       def __init__(self, cachedir):
                debug('Fetcher init %s' % cachedir)
-               self.ocean = ocean
                self.cachedir = cachedir
                try: os.mkdir(cachedir)
                except (OSError,IOError), oe:
                        if oe.errno != errno.EEXIST: raise
                self._cache_scan(time.time())
 
-       def default_ocean(self, ocean='ice'):
-               if self.ocean is None:
-                       self.ocean = ocean
-
        def _cache_scan(self, now):
                # returns list of ages, unsorted
                ages = []
@@ -148,7 +165,6 @@ class Fetcher:
                                        (min_age, age))
                                need_wait = max(need_wait, min_age - age)
                        min_age += 3
-                       min_age *= 1.25
                if need_wait > 0:
                        need_wait += random.random() - 0.5
                return need_wait
@@ -156,7 +172,7 @@ class Fetcher:
        def _rate_limit_cache_clean(self, now):
                need_wait = self.need_wait(now)
                if need_wait > 0:
-                       debug('Fetcher   wait %d' % need_wait)
+                       debug('Fetcher   wait %f' % need_wait)
                        sleep(need_wait)
 
        def fetch(self, url, max_age):
@@ -195,12 +211,38 @@ class Fetcher:
                debug('Fetcher  stored')
                return data
 
+class Yoweb(Fetcher):
+       def __init__(self, ocean, cachedir):
+               debug('Yoweb init %s' % cachedir)
+               self.ocean = ocean
+               Fetcher.__init__(self, cachedir)
+
+       def default_ocean(self, ocean='ice'):
+               if self.ocean is None:
+                       self.ocean = ocean
+
        def yoweb(self, kind, tail, max_age):
                self.default_ocean()
+               assert(self.ocean)
                url = 'http://%s.puzzlepirates.com/yoweb/%s%s' % (
                        self.ocean, kind, tail)
                return self.fetch(url, max_age)
 
+class Yppedia(Fetcher):
+       def __init__(self, cachedir):
+               debug('Yoweb init %s' % cachedir)
+               self.base = 'http://yppedia.puzzlepirates.com/'
+               self.localhtml = opts.localhtml
+               Fetcher.__init__(self, cachedir)
+
+       def __call__(self, rhs):
+               if self.localhtml is None:
+                       url = self.base + rhs
+                       debug('Yppedia retrieving YPP '+url);
+                       return self.fetch(url, 3000)
+               else:
+                       return file(opts.localhtml + '/' + rhs, 'r')
+
 #---------- logging assistance for troubled screenscrapers ----------
 
 class SoupLog:
@@ -222,9 +264,7 @@ class SomethingSoupInfo(SoupLog):
        def __init__(self, kind, tail, max_age):
                SoupLog.__init__(self)
                html = fetcher.yoweb(kind, tail, max_age)
-               self._soup = BeautifulSoup(html,
-                       convertEntities=BeautifulSoup.HTML_ENTITIES
-                       )
+               self._soup = make_soup(html)
 
 #---------- scraper for pirate pages ----------
 
@@ -331,12 +371,14 @@ u'\\s*\\S*/([-A-Za-z]+)\\s*$|\\s*\\S*/\\S*\\s*\\(ocean\\-wide(?:\\s|\\xa0)+([-A-
 
 class CrewInfo(SomethingSoupInfo):
        # Public data members:
+       #  ci.crewid
        #  ci.crew = [ ('Captain',        ['Pirate', ...]),
        #              ('Senior Officer', [...]),
        #               ... ]
        #  pi.msgs = [ 'message describing problem with scrape' ]
 
        def __init__(self, crewid, max_age=300):
+               self.crewid = crewid
                SomethingSoupInfo.__init__(self,
                        'crew/info.wm?crewid=', crewid, max_age)
                self._find_crew()
@@ -377,6 +419,322 @@ class CrewInfo(SomethingSoupInfo):
        def __str__(self):
                return `(self.crew, self.msgs)`
 
+class FlagRelation():
+       # Public data members (put there by hand by creater)
+       #       other_flagname
+       #       other_flagid
+       #       yoweb_heading
+       #       this_declaring
+       #       other_declaring_min
+       #       other_declaring_max
+       # where {this,other}_declaring{,_min,_max} are:
+       #       -1      {this,other} is declaring war
+       #        0      {this,other} is not doing either
+       #       +1      {this,other} is allying
+       def __repr__(self):
+               return '<FlagRelation %s %d/%d..%d %s %s>' % (
+                       self.yoweb_heading, self.this_declaring,
+                       self.other_declaring_min, self.other_declaring_max,
+                       self.other_flagname, self.other_flagid)
+
+class FlagInfo(SomethingSoupInfo):
+       # Public data members (after init):
+       #
+       #   flagid
+       #   name        #               string
+       #
+       #   relations[n] = FlagRelation
+       #   relation_byname[otherflagname] = relations[some_n]
+       #   relation_byid[otherflagname] = relations[some_n]
+       #
+       #   islands[n] = (islandname, islandid)
+       #
+       def __init__(self, flagid, max_age=600):
+               self.flagid = flagid
+               SomethingSoupInfo.__init__(self,
+                       'flag/info.wm?flagid=', flagid, max_age)
+               self._find_flag()
+
+       def _find_flag(self):
+               font2 = self._soup.find('font',{'size':'+2'})
+               self.name = font2.find('b').contents[0]
+
+               self.relations = [ ]
+               self.relation_byname = { }
+               self.relation_byid = { }
+               self.islands = [ ]
+
+               magnate = self._soup.find('img',{'src':
+                       '/yoweb/images/repute-MAGNATE.png'})
+               warinfo = (magnate.findParent('table').findParent('tr').
+                       findNextSibling('tr').findNext('td',{'align':'left'}))
+
+               def warn(m):
+                       print >>sys.stderr, 'WARNING: '+m
+
+               def wi_warn(head, waritem):
+                       warn('unknown warmap item: %s: %s' % 
+                               (`head`, ``waritem``))
+
+               def wihelp_item(waritem, thing):
+                       url = waritem.get('href', None)
+                       if url is None:
+                               return ('no url for '+thing,None,None)
+                       m = regexp.search('\?'+thing+'id=(\d+)$', url)
+                       if not m: return ('no '+thing+'id',None,None)
+                       tid = m.group(1)
+                       tname = waritem.string
+                       if tname is None:
+                               return (thing+' name not just string',None,None)
+                       return (None,tid,tname)
+
+               def wi_alwar(head, waritem, thisdecl, othermin, othermax):
+                       (err,flagid,flagname) = wihelp_item(waritem,'flag')
+                       if err: return err
+                       rel = self.relation_byid.get(flagid, None)
+                       if rel: return 'flag id twice!'
+                       if flagname in self.relation_byname:
+                               return 'flag name twice!'
+                       rel = FlagRelation()
+                       rel.other_flagname = flagname
+                       rel.other_flagid = flagid
+                       rel.yoweb_heading = head
+                       rel.this_declaring = thisdecl
+                       rel.other_declaring_min = othermin
+                       rel.other_declaring_max = othermax
+                       self.relations.append(rel)
+                       self.relation_byid[flagid] = rel
+                       self.relation_byname[flagid] = rel
+
+               def wi_isle(head, waritem):
+                       (err,isleid,islename) = wihelp_item(waritem,'island')
+                       if err: return err
+                       self.islands.append((isleid,islename))
+
+               warmap = {
+                       'Allied with':                  (wi_alwar,+1,+1,+1),
+                       'Declaring war against':        (wi_alwar,-1, 0,+1),
+                       'At war with':                  (wi_alwar,-1,-1,-1),
+                       'Trying to form an alliance with': (wi_alwar,+1,-1,0),
+                       'Islands controlled by this flag': (wi_isle,),
+                       }
+
+               how = (wi_warn, None)
+
+               for waritem in warinfo.findAll(['font','a']):
+                       if waritem is None: break
+                       if waritem.name == 'font':
+                               colour = waritem.get('color',None)
+                               if colour.lstrip('#') != '958A5F':
+                                       warn('strange colour %s in %s' %
+                                               (colour,``waritem``))
+                                       continue
+                               head = waritem.string
+                               if head is None:
+                                       warn('no head string in '+``waritem``)
+                                       continue
+                               head = regexp.sub('\\s+', ' ', head).strip()
+                               head = head.rstrip(':')
+                               how = (head,) + warmap.get(head, (wi_warn,))
+                               continue
+                       assert(waritem.name == 'a')                             
+
+                       debug('WARHOW %s(%s, waritem, *%s)' %
+                               (how[1], `how[0]`, `how[2:]`))
+                       bad = how[1](how[0], waritem, *how[2:])
+                       if bad:
+                               warn('bad waritem %s: %s: %s' % (`how[0]`,
+                                       bad, ``waritem``))
+
+       def __str__(self):
+               return `(self.name, self.islands, self.relations)`
+
+#---------- scraper for ocean info incl. embargoes etc. ----------
+
+class IslandBasicInfo():
+       # Public data attributes:
+       #  ocean
+       #  name
+       # Public data attributes maybe set by caller:
+       #  arch
+       def __init__(self, ocean, islename):
+               self.ocean = ocean
+               self.name = islename
+       def yppedia(self):
+               def q(x): return urllib.quote(x.replace(' ','_'))
+               url_rhs = q(self.name) + '_(' + q(self.ocean) + ')'
+               return yppedia(url_rhs)
+       def __str__(self):
+               return `(self.ocean, self.name)`
+
+class IslandExtendedInfo(IslandBasicInfo):
+       # Public data attributes (inherited):
+       #  ocean
+       #  name
+       # Public data attributes (additional):
+       #  islandid
+       #  yoweb_url
+       #  flagid
+       def __init__(self, ocean, islename):
+               IslandBasicInfo.__init__(self, ocean, islename)
+               self.islandid = None
+               self.yoweb_url = None
+               self._collect_yoweb()
+               self._collect_flagid()
+
+       def _collect_yoweb(self):
+               debug('IEI COLLECT YOWEB '+`self.name`)
+               self.islandid = None
+               self.yoweb_url = None
+
+               soup = make_soup(self.yppedia())
+               content = soup.find('div', attrs = {'id': 'content'})
+               yoweb_re = regexp.compile('^http://\w+\.puzzlepirates\.com/'+
+                       'yoweb/island/info\.wm\?islandid=(\d+)$')
+               a = soup.find('a', attrs = { 'href': yoweb_re })
+               if a is None:
+                       debug('IEI COLLECT YOWEB '+`self.name`+' NONE')
+                       return
+
+               debug('IEI COLLECT YOWEB '+`self.name`+' GOT '+``a``)
+               self.yoweb_url = a['href']
+               m = yoweb_re.search(self.yoweb_url)
+               self.islandid = m.group(1)
+
+       def _collect_flagid(self):
+               self.flagid = None
+
+               yo = self.yoweb_url
+               debug('IEI COLLECT FLAGID '+`self.name`+' URL '+`yo`)
+               if yo is None: return None
+               dataf = fetcher.fetch(yo, 1800)
+               soup = make_soup(dataf)
+               ruler_re = regexp.compile(
+                       '/yoweb/flag/info\.wm\?flagid=(\d+)$')
+               ruler = soup.find('a', attrs = { 'href': ruler_re })
+               if not ruler: 
+                       debug('IEI COLLECT FLAGID '+`self.name`+' NONE')
+                       return
+               debug('IEI COLLECT FLAGID '+`self.name`+' GOT '+``ruler``)
+               m = ruler_re.search(ruler['href'])
+               self.flagid = m.group(1)
+
+       def __str__(self):
+               return `(self.ocean, self.islandid, self.name,
+                       self.yoweb_url, self.flagid)`
+
+class IslandFlagInfo(IslandExtendedInfo):
+       # Public data attributes (inherited):
+       #  ocean
+       #  name
+       #  islandid
+       #  yoweb_url
+       #  flagid
+       # Public data attributes (additional):
+       #  flag
+       def __init__(self, ocean, islename):
+               IslandExtendedInfo.__init__(self, ocean, islename)
+               self.flag = None
+               self._collect_flag()
+
+       def _collect_flag(self):
+               if self.flagid is None: return
+               self.flag = FlagInfo(self.flagid, 1800)
+
+       def __str__(self):
+               return IslandExtendedInfo.__str__(self) + '; ' + str(self.flag)
+
+class NullProgressReporter():
+       def doing(self, msg): pass
+       def stop(self): pass
+
+class TypewriterProgressReporter():
+       def __init__(self):
+               self._l = 0
+       def doing(self,m):
+               self._doing(m + '...')
+       def _doing(self,m):
+               self._write('\r')
+               self._write(m)
+               less = self._l - len(m)
+               if less > 0:
+                       self._write(' ' * less)
+                       self._write('\b' * less)
+               self._l = len(m)
+               sys.stdout.flush()
+       def stop(self):
+               self._doing('')
+               self._l = 0
+       def _write(self,t):
+               sys.stdout.write(t)
+
+class OceanInfo():
+       # Public data attributes:
+       #   oi.islands[islename] = IslandInfo(...)
+       #   oi.arches[archname][islename] = IslandInfo(...)
+       def __init__(self, isleclass=IslandBasicInfo):
+               self.isleclass = isleclass
+               self.ocean = fetcher.ocean.lower().capitalize()
+
+               progressreporter.doing('fetching ocean info')
+
+               cmdl = ['./yppedia-ocean-scraper']
+               if opts.localhtml is not None:
+                       cmdl += ['--local-html-dir',opts.localhtml]
+               cmdl += [self.ocean]
+               debug('OceanInfo collect running ' + `cmdl`)
+               oscraper = subprocess.Popen(
+                       cmdl,
+                       stdout = subprocess.PIPE,
+                       cwd = yppsc_dir()+'/yarrg',
+                       shell=False, stderr=None,
+                       )
+               h = oscraper.stdout.readline()
+               debug('OceanInfo collect h '+`h`)
+               assert(regexp.match('^ocean ', h))
+               arch_re = regexp.compile('^ (\S.*)')
+               island_re = regexp.compile('^  (\S.*)')
+
+               oscraper.wait()
+               assert(oscraper.returncode == 0)
+
+               self.islands = { }
+               self.arches = { }
+               archname = None
+
+               isles = [ ]
+               progressreporter.doing('parsing ocean info')
+
+               for l in oscraper.stdout:
+                       debug('OceanInfo collect l '+`l`)
+                       l = l.rstrip('\n')
+                       m = island_re.match(l)
+                       if m:
+                               assert(archname is not None)
+                               islename = m.group(1)
+                               isles.append((archname, islename))
+                               continue
+                       m = arch_re.match(l)
+                       if m:
+                               archname = m.group(1)
+                               assert(archname not in self.arches)
+                               self.arches[archname] = { }
+                               continue
+                       assert(False)
+
+               for i in xrange(0, len(isles)-1):
+                       (archname, islename) = isles[i]
+                       progressreporter.doing(
+                               'fetching isle info %2d/%d (%s: %s)'
+                               % (i, len(isles), archname, islename))
+                       isle = self.isleclass(self.ocean, islename)
+                       isle.arch = archname
+                       self.islands[islename] = isle
+                       self.arches[archname][islename] = isle
+
+       def __str__(self):
+               return `(self.islands, self.arches)`
+
 #---------- pretty-printer for tables of pirate puzzle standings ----------
 
 class StandingsTable:
@@ -395,12 +753,12 @@ class StandingsTable:
 
        def _nl(self): self._o('\n')
 
-       def _pline(self, pirate, puzstrs, extra):
+       def _pline(self, lhs, puzstrs, extra):
                if (self._linecount > 0
                    and self._gap_every is not None
                    and not (self._linecount % self._gap_every)):
                        self._nl()
-               self._o(' %-*s' % (max(max_pirate_namelen, 14), pirate))
+               self._o('%-*s' % (max(max_pirate_namelen+1, 15), lhs))
                for v in puzstrs:
                        self._o(' %-*.*s' % (self._cw,self._cw, v))
                if extra:
@@ -441,17 +799,18 @@ class StandingsTable:
                self._nl()
                self._linecount = 0
        def pirate_dummy(self, name, standingstring, extra=None):
-               self._pline(name, standingstring * len(self._puzzles), extra)
+               standings = standingstring * len(self._puzzles)
+               self._pline(' '+name, standings, extra)
        def pirate(self, pi, extra=None):
                puzstrs = [self._puzstr(pi,puz) for puz in self._puzzles]
-               self._pline(pi.name, puzstrs, extra)
+               self._pline(' '+pi.name, puzstrs, extra)
 
 
 #---------- chat log parser ----------
 
 class PirateAboard:
        # This is essentially a transparent, dumb, data class.
-       #  pa.v
+       #  pa.v                 may be None
        #  pa.name
        #  pa.last_time
        #  pa.last_event
@@ -460,6 +819,18 @@ class PirateAboard:
        #  pa.last_chat_chan
        #  pa.pi
 
+       # Also used for jobbing applicants:
+       #               happens when                    expires (to "-")
+       #   -            disembark, leaves crew          no
+       #   aboard       evidence of them being aboard   no
+       #   applied      "has applied for the job"       120s, configurable
+       #   ashore       "has taken a job"               30min, configurable
+       #   declined     "declined the job offer"        30s, configurable
+       #   invited      "has been invited to job"       120s, configurable
+       #
+       #  pa.jobber    None, 'ashore', 'applied', 'invited', 'declined'
+       #  pa.expires   expiry time time
+
        def __init__(pa, pn, v, time, event):
                pa.name = pn
                pa.v = v
@@ -469,6 +840,8 @@ class PirateAboard:
                pa.last_chat_chan = None
                pa.gunner = False
                pa.pi = None
+               pa.jobber = None
+               pa.expires = None
 
        def pirate_info(pa):
                now = time.time()
@@ -502,9 +875,15 @@ class ChatLogTracker:
                                # self._v = self._vl[self._vessel]
                self._date = None
                self._myself = myself_pi
-               self._f = file(logfn)
                self._lbuf = ''
-               self._progress = [0, os.fstat(self._f.fileno()).st_size]
+               self._f = file(logfn)
+               flen = os.fstat(self._f.fileno()).st_size
+               max_backlog = 500000
+               if flen > max_backlog:
+                       startpos = flen - max_backlog
+                       self._f.seek(startpos)
+                       self._f.readline()
+               self._progress = [0, flen - self._f.tell()]
                self._disembark_myself()
                self._need_redisplay = False
                self._lastvessel = None
@@ -518,22 +897,39 @@ class ChatLogTracker:
                self._need_redisplay = True
 
        def _vessel_updated(self, v, timestamp):
+               if v is None: return
                v['#lastinfo'] = timestamp
                self.force_redisplay()
 
-       def _onboard_event(self,v,timestamp,pirate,event):
+       def _onboard_event(self,v,timestamp,pirate,event,jobber=None):
                pa = self._pl.get(pirate, None)
                if pa is not None and pa.v is v:
                        pa.last_time = timestamp
                        pa.last_event = event
                else:
-                       if pa is not None: del pa.v[pirate]
+                       if pa is not None and pa.v is not None:
+                               del pa.v[pirate]
                        pa = PirateAboard(pirate, v, timestamp, event)
                        self._pl[pirate] = pa
-                       v[pirate] = pa
+                       if v is not None: v[pirate] = pa
+               pa.jobber = jobber
+
+               if jobber is None: timeout = None
+               else: timeout = getattr(opts, 'timeout_'+jobber)
+               if timeout is None: pa.expires = None
+               else: pa.expires = timestamp + timeout
                self._vessel_updated(v, timestamp)
                return pa
 
+       def _expire_jobbers(self, now):
+               for pa in self._pl.values():
+                       if pa.expires is None: continue
+                       if pa.expires >= now: continue
+                       v = pa.v
+                       del self._pl[pa.name]
+                       if v is not None: del v[pa.name]
+                       self.force_redisplay()
+
        def _trash_vessel(self, v):
                for pn in v:
                        if pn.startswith('#'): continue
@@ -636,9 +1032,67 @@ class ChatLogTracker:
        def _debug_line_disposition(self,timestamp,l,m):
                debug('CLT %13s %-40s %s' % (timestamp,m,l))
 
+       def _rm_crew_l(self,re,l):
+               m = regexp.match(re,l)
+               if m and m.group(2) == self._myself.crew[1]:
+                       return m.group(1)
+               else:
+                       return None
+
+       def local_command(self, metacmd):
+               # returns None if all went well, or problem message
+               return self._command(self._myself.name, metacmd,
+                       "local", time.time(), 
+                       (lambda m: debug('CMD %s' % metacmd)))
+
+       def _command(self, cmdr, metacmd, chan, timestamp, d):
+               # returns None if all went well, or problem message
+               metacmd = regexp.sub('\\s+', ' ', metacmd).strip()
+               m2 = regexp.match(
+                   '/([adj]) (?:([A-Za-z* ]+)\\s*:)?([A-Za-z ]+)$',
+                   metacmd)
+               if not m2: return "unknown syntax or command"
+
+               (cmd, pattern, targets) = m2.groups()
+               dml = ['cmd', chan, cmd]
+
+               if cmd == 'a': each = self._onboard_event
+               elif cmd == 'd': each = disembark
+               else: each = lambda *l: self._onboard_event(*l,
+                               **{'jobber':'applied'})
+
+               if cmdr == self._myself.name:
+                       dml.append('self')
+                       how = 'cmd: %s' % cmd
+               else:
+                       dml.append('other')
+                       how = 'cmd: %s %s' % (cmd,cmdr)
+
+               if cmd == 'j':
+                       if pattern is not None:
+                               return "/j command does not take a vessel"
+                       v = None
+               else:
+                       v = self._find_matching_vessel(
+                               pattern, timestamp, cmdr,
+                               dml, create=True)
+
+               if cmd == 'j' or v is not None:
+                       targets = targets.strip().split(' ')
+                       dml.append(`len(targets)`)
+                       for target in targets:
+                               each(v, timestamp, target.title(), how)
+                       self._vessel_updated(v, timestamp)
+
+               dm = ' '.join(dml)
+               return d(dm)
+
+               return None
+
        def chatline(self,l):
                rm = lambda re: regexp.match(re,l)
                d = lambda m: self._debug_line_disposition(timestamp,l,m)
+               rm_crew = lambda re: self._rm_crew_l(re,l)
                timestamp = None
 
                m = rm('=+ (\\d+)/(\\d+)/(\\d+) =+$')
@@ -674,6 +1128,13 @@ class ChatLogTracker:
                def ob1(did): ob_x(m.group(1), did); return d(did)
                def oba(did): return ob1('%s %s' % (did, m.group(2)))
 
+               def jb(pirate,jobber):
+                       return self._onboard_event(
+                               None, timestamp, pirate,
+                               ("jobber %s" % jobber),
+                               jobber=jobber
+                               )
+
                def disembark(v, timestamp, pirate, event):
                        self._onboard_event(
                                        v, timestamp, pirate, 'leaving '+event)
@@ -716,7 +1177,8 @@ class ChatLogTracker:
                def chat_core(speaker, chan):
                        try: pa = self._pl[speaker]
                        except KeyError: return 'mystery'
-                       if pa.v is not self._v: return 'elsewhere'
+                       if pa.v is not None and pa.v is not self._v:
+                               return 'elsewhere'
                        pa.last_chat_time = timestamp
                        pa.last_chat_chan = chan
                        self.force_redisplay()
@@ -729,38 +1191,12 @@ class ChatLogTracker:
 
                def chat_metacmd(chan):
                        (cmdr, metacmd) = m.groups()
-                       metacmd = regexp.sub('\\s+', ' ', metacmd).strip()
-                       m2 = regexp.match(
-                               '/([ad]) (?:([A-Za-z* ]+)\\s*:)?([A-Za-z ]+)$',
-                               metacmd)
-                       if not m2: return chat(chan)
-
-                       (cmd, pattern, targets) = m2.groups()
-                       dml = ['cmd', chan, cmd]
-
-                       if cmd == 'a': each = self._onboard_event
-                       else: each = disembark
-
-                       if cmdr == self._myself.name:
-                               dml.append('self')
-                               how = 'cmd: %s' % cmd
+                       whynot = self._command(
+                               cmdr, metacmd, chan, timestamp, d)
+                       if whynot is not None:
+                               return chat(chan)
                        else:
-                               dml.append('other')
-                               how = 'cmd: %s %s' % (cmd,cmdr)
-
-                       v = self._find_matching_vessel(
-                               pattern, timestamp, cmdr, dml, create=True)
-
-                       if v is not None:
-                               targets = targets.strip().split(' ')
-                               dml.append(`len(targets)`)
-                               for target in targets:
-                                       each(v, timestamp, target.title(), how)
-                               self._vessel_updated(v, timestamp)
-
-                       dm = ' '.join(dml)
-                       chat_core(cmdr, 'cmd '+chan)
-                       return d(dm)
+                               chat_core(cmdr, 'cmd '+chan)
 
                m = rm('(\\w+) (?:issued an order|ordered everyone) "')
                if m: return ob1('general order');
@@ -809,6 +1245,23 @@ class ChatLogTracker:
                        ' to the rest of the ship\\.')
                if m: return ob1('boarder unrepelled');
 
+               pirate = rm_crew("(\\w+) has taken a job with '(.*)'\\.")
+               if pirate: return jb(pirate, 'ashore')
+
+               pirate = rm_crew("(\\w+) has left '(.*)'\\.")
+               if pirate:
+                       disembark(self._v, timestamp, pirate, 'left crew')
+                       return d('left crew')
+
+               m = rm('(\w+) has applied for the posted job\.')
+               if m: return jb(m.group(1), 'applied')
+
+               pirate= rm_crew("(\\w+) has been invited to job for '(.*)'\\.")
+               if pirate: return jb(pirate, 'invited')
+
+               pirate = rm_crew("(\\w+) declined the job offer for '(.*)'\\.")
+               if pirate: return jb(pirate, 'declined')
+
                m = rm('(\\w+) has left the vessel\.')
                if m:
                        pirate = m.group(1)
@@ -817,6 +1270,19 @@ class ChatLogTracker:
 
                return d('not-matched')
 
+       def _str_pa(self, pn, pa):
+               assert self._pl[pn] == pa
+               s = ' '*20 + "%s %-*s %13s %-30s %13s %-20s %13s" % (
+                       (' ','G')[pa.gunner],
+                       max_pirate_namelen, pn,
+                       pa.last_time, pa.last_event,
+                       pa.last_chat_time, pa.last_chat_chan,
+                       pa.jobber)
+               if pa.expires is not None:
+                       s += " %-5d" % (pa.expires - pa.last_time)
+               s += "\n"
+               return s
+
        def _str_vessel(self, vn, v):
                s = ' vessel %s\n' % vn
                s += ' '*20 + "%-*s   %13s\n" % (
@@ -827,12 +1293,7 @@ class ChatLogTracker:
                        if pn.startswith('#'): continue
                        pa = v[pn]
                        assert pa.v == v
-                       assert self._pl[pn] == pa
-                       s += ' '*20 + "%s %-*s %13s %-30s %13s %s\n" % (
-                               (' ','G')[pa.gunner],
-                               max_pirate_namelen, pn,
-                               pa.last_time, pa.last_event,
-                               pa.last_chat_time, pa.last_chat_chan)
+                       s += self._str_pa(pn,pa)
                return s
 
        def __str__(self):
@@ -847,10 +1308,14 @@ class ChatLogTracker:
                for vn in sorted(self._vl.keys()):
                        if vn == self._vessel: continue
                        s += self._str_vessel(vn, self._vl[vn])
+               s += " elsewhere\n"
                for p in self._pl:
                        pa = self._pl[p]
-                       assert pa.v[p] is pa
-                       assert pa.v in self._vl.values()
+                       if pa.v is not None:
+                               assert pa.v[p] is pa
+                               assert pa.v in self._vl.values()
+                       else:
+                               s += self._str_pa(pa.name, pa)
                s += '>\n'
                return s
 
@@ -868,6 +1333,8 @@ class ChatLogTracker:
                                self._lbuf = ''
                                if opts.debug >= 2:
                                        debug(self.__str__())
+               self._expire_jobbers(time.time())
+
                if progress: progress.caughtup()
 
        def changed(self):
@@ -896,6 +1363,17 @@ class ChatLogTracker:
                return [ v[pn]
                         for pn in sorted(v.keys())
                         if not pn.startswith('#') ]
+       def jobbers(self):
+               # returns a the jobbers' PirateAboards,
+               # sorted by jobber class and reverse of expiry time
+               l = [ pa
+                     for pa in self._pl.values()
+                     if pa.jobber is not None
+                   ]
+               def compar_key(pa):
+                       return (pa.jobber, -pa.expires)
+               l.sort(key = compar_key)
+               return l
 
 #---------- implementations of actual operation modes ----------
 
@@ -906,16 +1384,29 @@ def do_pirate(pirates, bu):
                print '%s: %s,' % (`pirate`, info)
        print '}'
 
-def prep_crew_of(args, bu, max_age=300):
-       if len(args) != 1: bu('crew-of takes one pirate name')
+def prep_crewflag_of(args, bu, max_age, selector, constructor):
+       if len(args) != 1: bu('crew-of etc. take one pirate name')
        pi = PirateInfo(args[0], max_age)
-       if pi.crew is None: return None
-       return CrewInfo(pi.crew[0], max_age)
+       cf = selector(pi)
+       if cf is None: return None
+       return constructor(cf[0], max_age)
+
+def prep_crew_of(args, bu, max_age=300):
+       return prep_crewflag_of(args, bu, max_age,
+               (lambda pi: pi.crew), CrewInfo)
+
+def prep_flag_of(args, bu, max_age=300):
+       return prep_crewflag_of(args, bu, max_age,
+               (lambda pi: pi.flag), FlagInfo)
 
 def do_crew_of(args, bu):
        ci = prep_crew_of(args, bu)
        print ci
 
+def do_flag_of(args, bu):
+       fi = prep_flag_of(args, bu)
+       print fi
+
 def do_standings_crew_of(args, bu):
        ci = prep_crew_of(args, bu, 60)
        tab = StandingsTable(sys.stdout)
@@ -928,6 +1419,101 @@ def do_standings_crew_of(args, bu):
                        pi = PirateInfo(p, random.randint(900,1800))
                        tab.pirate(pi)
 
+def do_ocean(args, bu):
+       if (len(args)): bu('ocean takes no further arguments')
+       fetcher.default_ocean()
+       oi = OceanInfo(IslandFlagInfo)
+       print oi
+       for islename in sorted(oi.islands.keys()):
+               isle = oi.islands[islename]
+               print isle
+
+def do_embargoes(args, bu):
+       if (len(args)): bu('ocean takes no further arguments')
+       fetcher.default_ocean()
+       oi = OceanInfo(IslandFlagInfo)
+       wr = sys.stdout.write
+       print ('EMBARGOES:  Island    | Owning flag'+
+               '                    | Embargoed flags')
+
+       def getflname(isle):
+               if isle.islandid is None: return 'uncolonisable'
+               if isle.flag is None: return 'uncolonised'
+               return isle.flag.name
+
+       progressreporter.stop()
+
+       for archname in sorted(oi.arches.keys()):
+               print 'ARCHIPELAGO: ',archname
+               for islename in sorted(oi.arches[archname].keys()):
+                       isle = oi.islands[islename]
+                       wr(' %-20s | ' % isle.name)
+                       flname = getflname(isle)
+                       wr('%-30s | ' % flname)
+                       flag = isle.flag
+                       if flag is None: print ''; continue
+                       delim = ''
+                       for rel in flag.relations:
+                               if rel.this_declaring >= 0: continue
+                               wr(delim)
+                               wr(rel.other_flagname)
+                               delim = '; '
+                       print ''
+
+def do_embargoes_flag_of(args, bu):
+       progressreporter.doing('fetching flag info')
+       fi = prep_flag_of(args, bu)
+       if fi is None:
+               progressreporter.stop()
+               print 'Pirate is not in a flag.'
+               return
+
+       oi = OceanInfo(IslandFlagInfo)
+
+       progressreporter.stop()
+       print ''
+
+       any = False
+       for islename in sorted(oi.islands.keys()):
+               isle = oi.islands[islename]
+               flag = isle.flag
+               if flag is None: continue
+               for rel in flag.relations:
+                       if rel.this_declaring >= 0: continue
+                       if rel.other_flagid != fi.flagid: continue
+                       if not any: print 'EMBARGOED:'
+                       any = True
+                       print "  %-30s (%s)" % (islename, flag.name)
+       if not any:
+               print 'No embargoes.'
+       print ''
+
+       war_flag(fi)
+       print ''
+
+def do_war_flag_of(args, bu):
+       fi = prep_flag_of(args, bu)
+       war_flag(fi)
+
+def war_flag(fi):
+       any = False
+       for certain in [True, False]:
+               anythis = False
+               for rel in fi.relations:
+                       if rel.this_declaring >= 0: continue
+                       if (rel.other_declaring_max < 0) != certain: continue
+                       if not anythis:
+                               if certain: m = 'SINKING PvP'
+                               else: m = 'RISK OF SINKING PvP'
+                               print '%s (%s):' % (m, rel.yoweb_heading)
+                       anythis = True
+                       any = True
+                       print " ", rel.other_flagname
+       if not any:
+               print 'No sinking PvP.'
+
+#----- modes which use the chat log parser are quite complex -----
+
 class ProgressPrintPercentage:
        def __init__(self, f=sys.stdout):
                self._f = f
@@ -943,8 +1529,6 @@ class ProgressPrintPercentage:
                self._f.write('                   \r')
                self._f.flush()
 
-#----- modes which use the chat log parser are quite complex -----
-
 def prep_chat_log(args, bu,
                progress=ProgressPrintPercentage(),
                max_myself_age=3600):
@@ -1116,29 +1700,50 @@ def ship_aid_core(myself, track, displayer, kreader):
        rotate_nya = '/-\\'
 
        sort = NameSorter()
+       clicmd = None
+       clierr = None
+       cliexec = None
 
        while True:
                track.catchup()
                now = time.time()
 
-               (vn, s) = find_vessel()
-               s = track.myname() + s
-               s += " at %s" % time.strftime("%Y-%m-%d %H:%M:%S")
-               s += kreader.info()
+               (vn, vs) = find_vessel()
+
+               s = ''
+               if cliexec is not None:
+                       s += '...'
+               elif clierr is not None:
+                       s += 'Error: '+clierr
+               elif clicmd is not None:
+                       s += '/' + clicmd
+               else:
+                       s = track.myname() + vs
+                       s += " at %s" % time.strftime("%Y-%m-%d %H:%M:%S")
+                       s += kreader.info()
                s += '\n'
 
+               tbl_s = StringIO()
+               tbl = StandingsTable(tbl_s)
+
                aboard = track.aboard(vn)
                sort.lsort_pa(aboard)
 
-               tbl_s = StringIO()
-               tbl = StandingsTable(tbl_s)
+               jobbers = track.jobbers()
 
-               if track.vesselname(): howmany = ' %d aboard' % len(aboard)
+               if track.vesselname(): howmany = 'aboard: %2d' % len(aboard)
                else: howmany = ''
 
                tbl.headings(howmany, '  sorted by '+sort.desc())
 
-               for pa in aboard:
+               last_jobber = None
+
+               for pa in aboard + jobbers:
+                       if pa.jobber != last_jobber:
+                               last_jobber = pa.jobber
+                               tbl.literalline('')
+                               tbl.literalline('jobbers '+last_jobber)
+
                        pi = pa.pirate_info()
 
                        xs = ''
@@ -1156,11 +1761,34 @@ def ship_aid_core(myself, track, displayer, kreader):
                displayer.show(s)
                tbl_s.close()
 
+               if cliexec is not None:
+                       clierr = track.local_command("/"+cliexec.strip())
+                       cliexec = None
+                       continue
+
                k = kreader.getch()
                if k is None:
                        rotate_nya = rotate_nya[1:3] + rotate_nya[0]
                        continue
 
+               if clierr is not None:
+                       clierr = None
+                       continue
+
+               if clicmd is not None:
+                       if k == '\r' or k == '\n':
+                               cliexec = clicmd
+                               clicmd = clicmdbase
+                       elif k == '\e' and clicmd != "":
+                               clicmd = clicmdbase
+                       elif k == '\33':
+                               clicmd = None
+                       elif k == '\b' or k == '\177':
+                               clicmd = clicmd[ 0 : len(clicmd)-1 ]
+                       else:
+                               clicmd += k
+                       continue
+
                if k == 'q': break
                elif k == 'g': sort = SkillSorter('Gunning')
                elif k == 'c': sort = SkillSorter('Carpentry')
@@ -1170,6 +1798,8 @@ def ship_aid_core(myself, track, displayer, kreader):
                elif k == 'd': sort = SkillSorter('Battle Navigation')
                elif k == 't': sort = SkillSorter('Treasure Haul')
                elif k == 'a': sort = NameSorter()
+               elif k == '/': clicmdbase = ""; clicmd = clicmdbase
+               elif k == '+': clicmdbase = "a "; clicmd = clicmdbase
                else: pass # unknown key command
 
 #---------- individual keystroke input ----------
@@ -1203,7 +1833,7 @@ class KeystrokeReader(DummyKeystrokeReader):
 #---------- main program ----------
 
 def main():
-       global opts, fetcher
+       global opts, fetcher, yppedia, progressreporter
 
        pa = OptionParser(
 '''usage: .../yoweb-scrape [OPTION...] ACTION [ARGS...]
@@ -1212,6 +1842,8 @@ actions:
  yoweb-scrape [--ocean OCEAN ...] crew-of PIRATE
  yoweb-scrape [--ocean OCEAN ...] standings-crew-of PIRATE
  yoweb-scrape [--ocean OCEAN ...] track-chat-log CHAT-LOG
+ yoweb-scrape [--ocean OCEAN ...] ocean|embargoes
+ yoweb-scrape [--ocean OCEAN ...] war-flag-of|embargoes-flag-of PIRATE
  yoweb-scrape [options] ship-aid CHAT-LOG  (must be .../PIRATE_OCEAN_chat-log*)
 
 display modes (for --display) apply to ship-aid:
@@ -1232,6 +1864,17 @@ display modes (for --display) apply to ship-aid:
        ao('--display', action='store', dest='display',
                type='choice', choices=['dumb','overwrite'],
                help='how to display ship aid')
+       ao('--local-ypp-dir', action='store', dest='localhtml',
+               help='get yppedia pages from local directory LOCALHTML'+
+                       ' instead of via HTTP')
+
+       ao_jt = lambda wh, t: ao(
+               '--timeout-sa-'+wh, action='store', dest='timeout_'+wh,
+               default=t, help=('set timeout for expiring %s jobbers' % wh))
+       ao_jt('applied',      120)
+       ao_jt('invited',      120)
+       ao_jt('declined',      30)
+       ao_jt('ashore',      1800)
 
        ao('--ship-duty', action='store_true', dest='ship_duty',
                help='show ship duty station puzzles')
@@ -1274,7 +1917,13 @@ display modes (for --display) apply to ship-aid:
                else:
                        opts.display = 'overwrite'
 
-       fetcher = Fetcher(opts.ocean, opts.cache_dir)
+       fetcher = Yoweb(opts.ocean, opts.cache_dir)
+       yppedia = Yppedia(opts.cache_dir)
+
+       if opts.debug or not os.isatty(0):
+                progressreporter = NullProgressReporter()
+       else:
+               progressreporter = TypewriterProgressReporter()
 
        mode_fn(args[1:], pa.error)