1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
|
package transcript
import (
"regexp"
"strings"
"github.com/terst/listen/server/internal/advertising"
)
/*
Advertising read out of an episode's words.
A second detector, and it finds a class the audio one structurally cannot. The audio scan
compares episodes mirrored within a minute of each other, so what it finds is the advertising a
CDN stitched into all of them -- validated against the declared-versus-served length gap. A show
that inserts nothing has no gap, which by that measure means no advertising, and the host-read
sponsor block in the middle of it is invisible.
Every published transcript this server can use belongs to exactly such a show, because having no
inserted advertising is the condition for using one. So the two detectors do not merely fail
differently; they look at disjoint sets of episodes.
Nothing here is published as a skip. These are candidates, recorded and reported, and the
listener's labels are what can confirm them -- the same posture the signature library's shadow
run takes, and for the same reason: a detector that acts on its own first guesses makes
untrustworthy skips, and a skip into the middle of the programme is worse than no skip.
*/
// Candidate is a stretch of an episode whose words read as advertising.
type Candidate struct {
StartMs int64 `json:"startMs"`
EndMs int64 `json:"endMs"`
// Signals are the kinds of marker that qualified it, so the candidate can be weighed
// rather than taken on faith -- the same reason a published break carries its partner count.
Signals []string `json:"signals"`
}
const (
// bridgeMs is how far apart two markers may be and still belong to one read. Measured on
// the Seahawks Rewind block, where "presented by" and "12% off" are 17.7 seconds apart
// with the product description between them; 25 gives that room without reaching the
// programme on either side.
bridgeMs = 25_000
// minCandidateMs is the shortest thing worth calling an advertisement. One advertising slot:
// anything briefer is a mention rather than a break, and skipping it would clip the show.
minCandidateMs = advertising.UnitMs
// minKinds is how many different kinds of marker must appear. Two, because one kind
// repeated says one thing: a host saying "sponsor" four times while describing a
// sponsorship deal is the programme, which is the same trap the toc=false chapter rule
// documents. Real examples of both are in the tests.
minKinds = 2
)
// marker is one kind of advertising language. Kind rather than phrase, so several wordings of
// the same thing -- "sponsored by", "brought to you by", "presented by" -- count once.
type marker struct {
kind string
pattern *regexp.Regexp
}
var markers = []marker{
{"sponsor", regexp.MustCompile(
`sponsored by|brought to you by|presented by|our sponsor|today's sponsor|this episode is sponsored`)},
{"code", regexp.MustCompile(
`promo code|promotional code|use code|discount code|code [a-z0-9]+ at checkout|at checkout`)},
// "dot com" rather than ".com" because this is spoken text: a transcript writes the URL
// the way it was said. The campaign path has to follow a domain -- a bare "slash" matches
// ordinary speech, and the corpus has "a children's movie slash book for adults" to prove
// it.
{"url", regexp.MustCompile(
`dot com|dot co dot uk|\.com[/ ]|(dot com|\.com) slash |visit [a-z0-9]+ dot `)},
{"offer", regexp.MustCompile(
`\d+% off|\d+ percent off|free trial|first month free|save up to|limited time|` +
`for a limited|your first order|sign up today|enroll in`)},
{"legal", regexp.MustCompile(
`terms and conditions|terms apply|restrictions apply|see site for details|while supplies last`)},
}
// Spot reads an episode's words and reports the stretches that sound like advertising.
//
// Density rather than any single phrase. One marker is a mention -- a show is routinely
// "brought to you by" somebody in a single line of play-by-play, and a guest describing a
// sponsorship deal says "sponsor" repeatedly -- and both of those are the programme. A real
// read stacks different kinds of marker within a few seconds: who it is for, where to go, what
// the offer is, and what the terms are.
func Spot(cues []Cue) []Candidate {
type hit struct {
cue Cue
kinds []string
}
var hits []hit
for _, cue := range cues {
if kinds := kindsIn(cue.Text); len(kinds) > 0 {
hits = append(hits, hit{cue: cue, kinds: kinds})
}
}
var found []Candidate
for start := 0; start < len(hits); {
end := start
// Grow the run while the next marker is near enough to belong to the same read.
for end+1 < len(hits) && hits[end+1].cue.StartMs-hits[end].cue.EndMs <= bridgeMs {
end++
}
kinds := map[string]bool{}
for _, h := range hits[start : end+1] {
for _, k := range h.kinds {
kinds[k] = true
}
}
candidate := Candidate{
StartMs: hits[start].cue.StartMs,
EndMs: hits[end].cue.EndMs,
Signals: sortedKinds(kinds),
}
if len(kinds) >= minKinds && candidate.EndMs-candidate.StartMs >= minCandidateMs {
found = append(found, candidate)
}
start = end + 1
}
return found
}
func kindsIn(text string) []string {
lower := strings.ToLower(text)
var kinds []string
for _, m := range markers {
if m.pattern.MatchString(lower) {
kinds = append(kinds, m.kind)
}
}
return kinds
}
// sortedKinds returns the kinds in the fixed order markers are declared in, so a candidate
// reads the same way every time it is reported.
func sortedKinds(present map[string]bool) []string {
var out []string
for _, m := range markers {
if present[m.kind] {
out = append(out, m.kind)
}
}
return out
}
|