Add slug-based dedup and auto-create third parties from OCD

Add a slug column to common_third_parties for stable cross-source
deduplication. The OCD tracker patterns importer now resolves third
parties via a three-step cascade (slug lookup, domain fallback,
auto-create) instead of silently dropping unmatched links.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-14 17:08:56 +04:00
parent a16821802e
commit 53e1260d4d
4 changed files with 249 additions and 28 deletions

View File

@@ -14,7 +14,7 @@
// Command common-third-parties-import seeds the common_third_parties table from
// packages/thirdParties/data.json. It is idempotent: re-running upserts on conflict
// (lower(name)) so existing rows keep their id and created_at.
// (slug) so existing rows keep their id and created_at.
//
// When -fetch-logos is set, the tool inspects each third party's website to
// find the best available logo (SVG icon, apple-touch-icon, etc.) and stores
@@ -44,6 +44,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/slug"
"go.probo.inc/probo/pkg/version"
"go.probo.inc/probo/pkg/webinspect"
)
@@ -177,6 +178,7 @@ func run() error {
party := coredata.CommonThirdParty{
ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType),
Name: tp.Name,
Slug: slug.Make(tp.Name),
Category: parseCategory(tp),
HeadquarterAddress: tp.HeadquarterAddress,
LegalName: tp.LegalName,

View File

@@ -19,16 +19,17 @@
// existing rows are updated on the unique constraint so ids and created_at
// are preserved.
//
// Entries are linked to the matching common_third_parties row (by
// case-insensitive name lookup on the platform key). Entries whose platform
// cannot be resolved are inserted with a NULL common_third_party_id.
// Entries are linked to the matching common_third_parties row via a
// three-step cascade: slug lookup, domain lookup, then auto-create.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"math"
"net"
"net/url"
"os"
@@ -43,6 +44,7 @@ import (
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/slug"
)
const (
@@ -68,6 +70,8 @@ type (
TrackerType string
MatchType string
ThirdPartyName *string
Domain string
Category string
Description string
MaxAgeSeconds *int
Confidence float32
@@ -119,6 +123,7 @@ func run() error {
fmt.Printf("importing %d common tracker patterns from Open Cookie Database\n", len(patterns))
var inserted, updated, skipped int
var partiesCreated int
if err := pgClient.WithTx(
ctx,
@@ -127,26 +132,9 @@ func run() error {
thirdPartyCache := make(map[string]*gid.GID)
for _, p := range patterns {
var thirdPartyID *gid.GID
if p.ThirdPartyName != nil {
if cached, ok := thirdPartyCache[*p.ThirdPartyName]; ok {
thirdPartyID = cached
} else {
var party coredata.CommonThirdParty
if err := party.LoadByName(ctx, tx, *p.ThirdPartyName); err != nil {
fmt.Fprintf(
os.Stderr,
"warning: cannot find third party %q for pattern %q, skipping link\n",
*p.ThirdPartyName,
p.Pattern,
)
thirdPartyCache[*p.ThirdPartyName] = nil
} else {
thirdPartyCache[*p.ThirdPartyName] = &party.ID
thirdPartyID = &party.ID
}
}
thirdPartyID, err := resolveThirdParty(ctx, tx, p, thirdPartyCache, now, &partiesCreated)
if err != nil {
return err
}
trackerType, err := parseTrackerType(p.TrackerType)
@@ -195,11 +183,12 @@ func run() error {
}
fmt.Printf(
"imported %d patterns (%d inserted, %d updated, %d skipped)\n",
"imported %d patterns (%d inserted, %d updated, %d skipped, %d third parties auto-created)\n",
len(patterns)-skipped,
inserted,
updated,
skipped,
partiesCreated,
)
return nil
@@ -271,6 +260,8 @@ func loadPatternsFromOCD(dir string) ([]trackerPatternData, error) {
TrackerType: "COOKIE",
MatchType: matchType,
ThirdPartyName: new(platform),
Domain: e.Domain,
Category: e.Category,
Description: e.Description,
MaxAgeSeconds: parseRetentionPeriod(e.RetentionPeriod),
Confidence: 1.0,
@@ -282,6 +273,136 @@ func loadPatternsFromOCD(dir string) ([]trackerPatternData, error) {
return patterns, nil
}
func resolveThirdParty(
ctx context.Context,
tx pg.Tx,
p trackerPatternData,
cache map[string]*gid.GID,
now time.Time,
created *int,
) (*gid.GID, error) {
if p.ThirdPartyName == nil || *p.ThirdPartyName == "" {
return nil, nil
}
platformSlug := slug.Make(*p.ThirdPartyName)
if platformSlug == "" {
return nil, nil
}
if cached, ok := cache[platformSlug]; ok {
return cached, nil
}
var party coredata.CommonThirdParty
if err := party.LoadBySlug(ctx, tx, platformSlug); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return nil, fmt.Errorf("cannot look up common third party by slug %q: %w", platformSlug, err)
}
} else {
cache[platformSlug] = &party.ID
return &party.ID, nil
}
domain := normalizeDomain(p.Domain)
if domain != "" {
var domainRow coredata.CommonThirdPartyDomain
if err := domainRow.LoadByDomain(ctx, tx, domain); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return nil, fmt.Errorf("cannot look up common third party by domain %q: %w", domain, err)
}
} else if err := party.LoadByID(ctx, tx, domainRow.CommonThirdPartyID); err == nil {
cache[platformSlug] = &party.ID
return &party.ID, nil
}
}
party = coredata.CommonThirdParty{
ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType),
Name: *p.ThirdPartyName,
Slug: platformSlug,
Category: mapOCDCategory(p.Category),
Certifications: []string{},
CreatedAt: now,
UpdatedAt: now,
}
if _, err := party.Upsert(ctx, tx); err != nil {
return nil, fmt.Errorf("cannot auto-create common third party %q: %w", *p.ThirdPartyName, err)
}
if err := party.LoadBySlug(ctx, tx, platformSlug); err != nil {
return nil, fmt.Errorf("cannot reload auto-created common third party %q: %w", *p.ThirdPartyName, err)
}
if domain != "" {
d := coredata.CommonThirdPartyDomain{
ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyDomainEntityType),
CommonThirdPartyID: party.ID,
Domain: domain,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := d.Upsert(ctx, tx); err != nil {
return nil, fmt.Errorf("cannot upsert domain %q for %q: %w", domain, *p.ThirdPartyName, err)
}
}
*created++
cache[platformSlug] = &party.ID
return &party.ID, nil
}
var domainValidRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z]$`)
func normalizeDomain(s string) string {
s = strings.TrimSpace(s)
// Strip zero-width spaces and other Unicode control characters.
s = strings.Map(func(r rune) rune {
if r == '\u200b' || r == '\ufeff' {
return -1
}
return r
}, s)
// Take only the first domain when "or" separates multiple values,
// e.g. ".bing.com (3rd party) or .microsoft.com (3rd party)".
if idx := strings.Index(s, " or "); idx != -1 {
s = s[:idx]
}
// Handle values starting with "or ", e.g. "or demdex.net (3rd party)".
s = strings.TrimPrefix(s, "or ")
if idx := strings.IndexByte(s, '('); idx != -1 {
s = strings.TrimSpace(s[:idx])
}
// Strip placeholders like "[account]".
if strings.ContainsAny(s, "[]") {
return ""
}
s = strings.TrimPrefix(s, ".")
s = strings.TrimSuffix(s, ".")
s = strings.TrimSpace(s)
if s == "" || !domainValidRe.MatchString(s) {
return ""
}
return s
}
func mapOCDCategory(s string) coredata.ThirdPartyCategory {
switch strings.ToLower(strings.TrimSpace(s)) {
case "analytics":
return coredata.ThirdPartyCategoryAnalytics
case "marketing":
return coredata.ThirdPartyCategoryMarketing
default:
return coredata.ThirdPartyCategoryOther
}
}
var retentionRe = regexp.MustCompile(`(?i)^(\d+)\s+(second|seconds|sec|secs|minute|minutes|mins|min|hour|hours|day|days|week|weeks|month|months|year|years)`)
func parseRetentionPeriod(s string) *int {
@@ -332,7 +453,7 @@ func parseRetentionPeriod(s string) *int {
return nil
}
result := n * multiplier
result := min(n*multiplier, math.MaxInt32)
return &result
}

View File

@@ -30,6 +30,7 @@ type (
CommonThirdParty struct {
ID gid.GID `db:"id"`
Name string `db:"name"`
Slug string `db:"slug"`
Category ThirdPartyCategory `db:"category"`
HeadquarterAddress *string `db:"headquarter_address"`
LegalName *string `db:"legal_name"`
@@ -62,6 +63,7 @@ func (t *CommonThirdParty) LoadByID(
SELECT
id,
name,
slug,
category,
headquarter_address,
legal_name,
@@ -117,6 +119,7 @@ func (t *CommonThirdParty) LoadByName(
SELECT
id,
name,
slug,
category,
headquarter_address,
legal_name,
@@ -163,6 +166,62 @@ LIMIT 1;
return nil
}
func (t *CommonThirdParty) LoadBySlug(
ctx context.Context,
conn pg.Querier,
slug string,
) error {
q := `
SELECT
id,
name,
slug,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
service_software_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
logo_file_id,
created_at,
updated_at
FROM
common_third_parties
WHERE
slug = @slug
LIMIT 1;
`
args := pgx.StrictNamedArgs{"slug": slug}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query common third party by slug: %w", err)
}
defer rows.Close()
row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CommonThirdParty])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect common third party by slug: %w", err)
}
*t = row
return nil
}
func (t CommonThirdParty) Insert(
ctx context.Context,
conn pg.Tx,
@@ -171,6 +230,7 @@ func (t CommonThirdParty) Insert(
INSERT INTO common_third_parties (
id,
name,
slug,
category,
headquarter_address,
legal_name,
@@ -192,6 +252,7 @@ INSERT INTO common_third_parties (
) VALUES (
@id,
@name,
@slug,
@category,
@headquarter_address,
@legal_name,
@@ -216,6 +277,7 @@ INSERT INTO common_third_parties (
args := pgx.StrictNamedArgs{
"id": t.ID,
"name": t.Name,
"slug": t.Slug,
"category": t.Category,
"headquarter_address": t.HeadquarterAddress,
"legal_name": t.LegalName,
@@ -244,7 +306,7 @@ INSERT INTO common_third_parties (
return nil
}
// Upsert inserts a row, or on lower(name) conflict updates every column except
// Upsert inserts a row, or on slug conflict updates every column except
// id and created_at. Returns true if a new row was inserted, false if an
// existing row was updated.
func (t CommonThirdParty) Upsert(
@@ -255,6 +317,7 @@ func (t CommonThirdParty) Upsert(
INSERT INTO common_third_parties (
id,
name,
slug,
category,
headquarter_address,
legal_name,
@@ -276,6 +339,7 @@ INSERT INTO common_third_parties (
) VALUES (
@id,
@name,
@slug,
@category,
@headquarter_address,
@legal_name,
@@ -295,7 +359,7 @@ INSERT INTO common_third_parties (
@created_at,
@updated_at
)
ON CONFLICT (lower(name)) DO UPDATE
ON CONFLICT (slug) DO UPDATE
SET
name = EXCLUDED.name,
category = EXCLUDED.category,
@@ -320,6 +384,7 @@ RETURNING (xmax = 0) AS inserted
args := pgx.StrictNamedArgs{
"id": t.ID,
"name": t.Name,
"slug": t.Slug,
"category": t.Category,
"headquarter_address": t.HeadquarterAddress,
"legal_name": t.LegalName,
@@ -384,6 +449,7 @@ func (t *CommonThirdParties) LoadAll(
SELECT
id,
name,
slug,
category,
headquarter_address,
legal_name,

View File

@@ -0,0 +1,32 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TABLE common_third_parties ADD COLUMN slug TEXT;
UPDATE common_third_parties
SET slug = lower(
trim(BOTH '-' FROM
regexp_replace(
regexp_replace(
regexp_replace(name, '[^a-zA-Z0-9 _-]', '', 'g'),
'[ _]+', '-', 'g'),
'-+', '-', 'g')
)
);
ALTER TABLE common_third_parties ALTER COLUMN slug SET NOT NULL;
DROP INDEX common_third_parties_name_key;
CREATE UNIQUE INDEX common_third_parties_slug_key ON common_third_parties (slug);