"""People in the user's podcasts (docs/podcasts.md, Stöbern): who is talked with, who gives a talk — found in episode titles and descriptions, no service asked. A name is two or three capitalised words ("Peter Decker", "Ulrike Eifler", "Jean-Luc Mélenchon"), possibly with a particle ("Wolfgang von Goethe"). Two kinds of evidence: - strong: right after a cue that announces a person — "mit", "w/", "with", "feat.", "Gast:", "im Gespräch mit", "interview with", "von" in a talk's title, " - Name - " between dashes; - weak: anywhere else in a title or description. A name counts when it has strong evidence in at least STRONG_MIN episodes, or turns up in at least FEEDS_MIN podcasts (a person known across the user's world). Words that are no names (places, institutions, weekdays, sentence starts) are left out by a list and by the rule that a name's first word must not be one of them. """ import html import re from collections import Counter, defaultdict _W = r"[A-ZÄÖÜÀ-Ý][a-zäöüßà-ÿ]+(?:-[A-ZÄÖÜÀ-Ý][a-zäöüßà-ÿ]+)?" _PART = r"(?:von|van|der|de|da|di|du|la|le|zu|ten|del|bin|al)" _NAME = rf"{_W}(?:\s+{_PART})?(?:\s+{_W}){{1,2}}" NAME_RE = re.compile(rf"(?]+>', ' ', text or '') return re.sub(r'\s+', ' ', html.unescape(text)).strip() def _clean(name): words = name.split() # "Mit Peter Decker" etc.: drop leading words that are no name. while words and words[0] in NOT_NAME_WORDS: words = words[1:] while words and words[-1] in NOT_NAME_WORDS: words = words[:-1] caps = [w for w in words if w[0].isupper()] if len(caps) < 2 or any(w in NOT_NAME_WORDS for w in caps): return None if any(len(w) > 1 and w.isupper() for w in caps): # "WAS TUN", "ARTE Reportage": no person return None return ' '.join(words) def mentions(title): """(strong, weak): the names in one episode's title.""" strong, weak = set(), set() for m in CUE_RE.finditer(title or ''): n = _clean(m.group(1) or m.group(2)) if n: strong.add(n) for m in NAME_RE.finditer(title or ''): n = _clean(m.group(1)) if n: weak.add(n) return strong, weak - strong def people(episodes, weights=None): """episodes: [(episode_id, feed_id, title)]; weights: {feed_id: how much the user listens} (heard_365). The people found, the most relevant first: [{'name', 'episodes', 'feeds'}]. Only titles: descriptions carry credits and boilerplate. First names are learnt from the strong mentions ("mit Peter Decker": Peter); a name found elsewhere in a title counts only with such a first name.""" found = [(ep_id, feed_id, *mentions(title)) for ep_id, feed_id, title in episodes] surnames = defaultdict(set) for _, _, strong, _ in found: for n in strong: w = n.split() surnames[w[0]].add(w[-1]) # A first name: one that comes with at least two different surnames in strong mentions. first_names = {f for f, last in surnames.items() if len(last) >= 2 and f not in NOT_NAME_WORDS} eps = defaultdict(set) feeds = defaultdict(set) strong_n = Counter() for ep_id, feed_id, strong, weak in found: for n in strong: strong_n[n] += 1 for n in {n for n in strong | weak if n.split()[0] in first_names}: eps[n].add(ep_id) feeds[n].add(feed_id) weights = weights or {} out = [] for n, ids in eps.items(): if strong_n[n] >= STRONG_MIN or len(feeds[n]) >= FEEDS_MIN: score = len(ids) + 5 * sum(min(weights.get(f, 0), 50) for f in feeds[n]) ** 0.5 out.append({'name': n, 'episodes': len(ids), 'feeds': len(feeds[n]), 'score': round(score, 1)}) out.sort(key=lambda p: (-p['score'], p['name'])) return out