Promptdetectie in Search Console
Vindt AI-prompts en AI Mode-vervolgvragen in je Search Console-data en scheidt trackerruis van echte mensen.
Wat doet deze skill?
Search Console toont steeds meer lange zoekopdrachten die eruitzien als een vraag aan ChatGPT. Een groot deel daarvan komt niet van mensen, maar van tools die prompts afvuren: prompttrackers, AI-agents en fan-outs van AI-systemen. Wie die queries gebruikt voor contentkeuzes, stuurt op ruis. Tegelijk laat AI Mode echte sporen na: Google telt een vervolgvraag als “ja graag” of “en voor 2026” als een nieuwe zoekopdracht.
Deze skill leest beide sporen. Hij schat per lange query hoe waarschijnlijk een tracker de bron is, zoekt de korte vervolgbeurten uit AI Mode en volgt hun trend per miljoen impressies, en koppelt alles aan je landingspagina’s.
Hoe werkt het?
- De skill vraagt naar property, land en de talen van je markt.
- Hij geeft je de regexfilters voor Search Console, of haalt de data op via een connector als die er is.
- Een script deelt elke lange query in een van tien groepen in, op basis van tekstpatronen, herhaling per dag, kliks en toestel.
- Een tweede script zoekt antwoorden aan een AI, vervolgvragen en gespreksvragen, en sluit gewone zoekvormen en vaste patronen uit.
- Een derde script koppelt beide sporen aan je pagina’s.
- De skill controleert de opvallende groepen en rapporteert met een duidelijke scheiding tussen gemeten en afgeleid.
Wat je krijgt
- Een werkboek voor het lange spoor: indeling per query, kans op tracker, impressies na correctie en een lijst kandidaten die van mensen kunnen komen.
- Een werkboek voor het korte spoor: AI Mode-sporen per query, per vorm en per maand, met het aandeel mobiel.
- Per URL het geschatte trackeraandeel en de pagina’s die Google toont bij een AI Mode-vervolgvraag.
- Een samenvatting met het eindcijfer, de trend en de beperkingen.
Goed om te weten
Search Console toont geen afzender en labelt geen AI Mode. Alle kansen en vormen zijn dus schattingen, en de skill noemt ze ook zo. Met alleen de standaardexport uit de interface vallen de herhalingstest en een deel van de uitsluitingen weg; de skill zegt welke. Zeldzame queries verbergt Google, dus de meeste vervolgvragen van echte mensen zie je nooit. De methode voor het korte spoor volgt het onderzoek van Jan-Willem Bobbink (GSC Wizard). Je data blijft in je eigen Claude-gesprek.
Zo start je hem
Typ een van deze zinnen in een nieuw gesprek, of iets gelijkaardigs.
- Hoeveel van mijn lange zoekopdrachten in Search Console komen van prompttrackers?
- Zie ik al AI Mode-verkeer in mijn Search Console? Hier is mijn export.
- Welke pagina's verschijnen bij vervolgvragen uit AI Mode, en waar gaan de kliks naartoe?
Wat er in de zip zit
5 bestanden, allemaal gewone tekst. Lees ze gerust na voor je de skill installeert.
gsc-prompt-detectie/SKILL.md11,9 kB
---
name: gsc-prompt-detectie
description: Vindt AI-prompt-achtige zoekopdrachten in Google Search Console-data en schat hoeveel ervan van trackers komt en hoeveel van echte mensen. Leest twee sporen, lange zinvormige queries (vaak prompttrackers, AI-agents en fan-outs) en korte vervolgbeurten uit AI Mode zoals ja graag, what about X of in het nederlands. Koppelt beide aan landingspagina's en levert Excel-werkboeken met indeling per query, trend per miljoen impressies en een overzicht per URL. Werkt met geüploade Search Console-exports (CSV of XLSX) en optioneel met een Search Console-connector of API-export. Gebruik deze skill wanneer iemand vraagt naar prompts in Search Console, lange queries, AI Mode-verkeer in GSC, trackerruis of botverkeer in zoekopdrachten, conversationele zoekopdrachten, of hoe AI-zoekgedrag zichtbaar wordt in de eigen data.
---
# Promptdetectie en AI Mode-sporen in Search Console
Search Console toont geen afzender en labelt geen AI Mode. Deze skill leest twee indirecte sporen en koppelt ze aan pagina's.
- **Lang spoor (trackerruis).** Lange, zinvormige queries komen voor een groot deel van tools die machinaal prompts afvuren: prompttrackers, AI-agents en fan-outs van AI-systemen. Noem dat in je antwoord een keer "tracker" en leg het uit. Het script schat per query hoe waarschijnlijk een tracker de bron is en geeft een lijst met kandidaten die wel van mensen kunnen komen.
- **Kort spoor (AI Mode-sporen).** Google telt een vervolgvraag in AI Mode als een nieuwe query. Een kale "ja graag", "what about developer" of "in het nederlands" heeft alleen betekenis als volgende beurt in een gesprek. Methode naar Jan-Willem Bobbink, [AI Mode queries in Search Console: what 155 sites show](https://www.gscwizard.com/learn/research/ai-mode-queries-in-search-console.html) (GSC Wizard, oktober 2026).
- **URL-koppeling.** Per pagina: hoeveel lange-query-impressies naar schatting van trackers komen, en welke pagina's Google toont bij een AI Mode-vervolgbeurt.
Draai standaard beide sporen en de URL-koppeling, voor zover de data het toelaat. Je verzint nooit cijfers: ontbreekt een export, zeg dan welke test daardoor wegvalt.
## Stap 0: invoer vastleggen
- **Property en land.** Draai per land. Een taalmap als property mag.
- **Talen van de markt** (bv. `nl,fr` of `de,en`). Zonder talen blijft groep B2 leeg.
- **Eigen promptlijst** (optioneel, waardevol). Gebruikt de gebruiker een AI-tracker, vraag dan de promptexport. Schrijf een prompt per regel naar `prompts.txt`. Alleen prompts van 7 woorden of meer kunnen matchen.
- **Einddatum:** vijf dagen geleden. Search Console voegt nog dagen later queryrijen toe, recente dagen staan te laag.
Vraag niet om bevestiging als property en land duidelijk zijn. Zeg welke aannames je maakte.
## Stap 1: data ophalen
Er zijn twee niveaus. Kies het hoogste dat beschikbaar is en zeg welk niveau je gebruikte.
**Niveau A: connector, API, Looker Studio of BigQuery-export.** Haal deze tabellen op, elk met het landfilter, rijlimiet zo hoog mogelijk:
| Bestand | Dimensies | Metrics | Queryfilter | Periode |
| --- | --- | --- | --- | --- |
| `queries.csv` | query | clicks, impressions, position | regex `^(\S+\s+){6,}\S+` (7+ woorden) | 12 maanden |
| `daily.csv` | date, query | impressions | idem | 6 maanden |
| `device.csv` | query, device | impressions | idem | 12 maanden |
| `lang_qp.csv` | query, page | clicks, impressions, position | idem | 12 maanden |
| `kort.csv` | date, query, device | clicks, impressions | AI Mode-regex (zie onder) | 16 maanden |
| `kort_qp.csv` | query, page | clicks, impressions, position | AI Mode-regex | 16 maanden |
| `totaal.csv` | date | clicks, impressions | geen | 16 maanden |
**Niveau B: alleen de Search Console-interface.** Prestaties, filter Zoekopdracht, Aangepast (regex), dan Exporteren als CSV of XLSX. De export bevat losse tabs (Zoekopdrachten, Pagina's, Apparaten, Datums). Vraag de gebruiker om:
1. Lang spoor: regex `^(\S+\s+){6,}\S+`, 12 maanden. Tab Zoekopdrachten wordt `queries`.
2. Lang spoor, toestel: dezelfde export twee keer met een extra filter Apparaat, een keer Computer en een keer Mobiel. Die twee tabs Zoekopdrachten geef je mee als `--desktop` en `--mobiel`.
3. Kort spoor: de AI Mode-regex, 16 maanden. Tab Zoekopdrachten wordt `kort`, tab Datums wordt `kort-datums`.
4. Noemer: zonder queryfilter, 16 maanden, tab Datums wordt `totaal`.
5. URL-koppeling: in de interface niet als een tabel te krijgen. Laat ze weg, of vraag voor de grootste pagina's een export per pagina (filter Pagina, tab Zoekopdrachten) en voeg ze samen tot query, page, clicks, impressions, position.
Wat wegvalt op niveau B: de herhalingstest (groepen B3 en C, vraagt dagdata per query), de uitsluiting van vaste patronen in het korte spoor, en de trend per vorm. De trend van het korte spoor komt dan uit de tab Datums en is niet gecorrigeerd voor uitgesloten queries. De interface toont maximaal 1.000 rijen per export. Vermeld dat allemaal in het antwoord.
De scripts herkennen Nederlandse en Engelse kolomkoppen (Populairste zoekopdrachten, Klikken, Vertoningen, Positie, Datum, Apparaat) en lezen CSV, TSV, XLSX (`bestand.xlsx#Tabblad`) en JSON met een veld `data`.
**AI Mode-regex** voor het filter, letterlijk te kopiëren:
```bash
python3 scripts/ai_mode_spoor.py --regex
```
## Stap 2: scripts draaien
De scripts staan in `scripts/` naast deze SKILL.md en vragen alleen `openpyxl`. Laat opties weg waarvoor geen bestand is.
```bash
mkdir -p out
python3 scripts/prompt_detectie.py --queries queries.csv \
[--daily daily.csv] [--device device.csv | --desktop desktop.csv --mobiel mobiel.csv] \
[--prompts prompts.txt] [--talen nl,fr] \
--label "site.be, België" --out out/site-be-lange-queries.xlsx
python3 scripts/ai_mode_spoor.py --kort kort.csv [--totaal totaal.csv] [--kort-datums kort-datums.csv] \
--label "site.be, België" --out out/site-be-ai-mode-sporen.xlsx
python3 scripts/url_koppeling.py \
--lang-wb out/site-be-lange-queries.xlsx --lang-qp lang_qp.csv \
--kort-wb out/site-be-ai-mode-sporen.xlsx --kort-qp kort_qp.csv
```
Elk script print een JSON-samenvatting: gebruik die cijfers in je antwoord, niet je eigen schatting. Naast elk werkboek staat een `.json` met de indeling per query; de URL-koppeling leest die. De URL-koppeling mag opnieuw draaien, ze vervangt de URL-tabs.
## Hoe het lange spoor indeelt
Een query krijgt de eerste groep waarvan de regel past.
| Groep | Regel | Kans tracker (geen toesteldata / alleen desktop / gemengd) |
| --- | --- | --- |
| A1 Eigen trackerprompt | Letterlijk gelijk aan een prompt uit de eigen lijst | 99 / 99 / 99% |
| A2 Instructie aan een AI | "my location is", "recherchiere online", "rank them" | 95 / 95 / 95% |
| A3 Sjabloon met invulvakken | Familie met dezelfde opening en slot, 0 kliks | 95 / 97 / 80% |
| B1 Persona-prompt | Opent met een rol of situatie, 10+ woorden | 85 / 92 / 60% |
| B2 Taal wijkt af van de markt | Promptvorm in een taal buiten de opgegeven talen | 80 / 85 / 60% |
| B3 Lange zin die terugkomt | 12+ woorden, leesteken, 5+ dagen, 0 kliks | 80 / 90 / 50% |
| C Vraagzin op 20+ dagen | Zin met leesteken, 20+ dagen, 0 kliks | 60 / 85 / 35% |
| D Zin, weinig herhaald | Zin met leesteken, minder dan 20 dagen | 30 / 70 / 15% |
| E Lange query zonder leesteken | 10+ woorden, geen ander signaal | 20 / 70 / 10% |
| F Zoekwoord-achtig | Hooguit 9 woorden, geen leesteken | 5 / 70 / 3% |
Toestelprofiel vanaf 30 impressies met toesteldata: "alleen desktop" bij hooguit 2% mobiel en tablet, anders "gemengd". Kansen zijn schattingen uit eerdere toepassingen. Pas ze aan in `GROEPEN` bovenaan `prompt_detectie.py` als een site een ander beeld geeft, en vermeld dat.
**Kandidaat natuurlijke prompt:** promptvorm buiten groep A en B, met een klik of met gemengd toestelverkeer (sterk), of met weinig impressies maar minstens een mobiele impressie (zwak).
## Hoe het korte spoor indeelt
| Vorm | Voorbeelden |
| --- | --- |
| Antwoord aan een AI | ja, ja graag, graag, ja bitte, oui svp, yes please, doe maar, meer |
| Vervolgvraag | what about developer, et pour la belgique, was ist mit, en als student |
| Gespreksvraag | in het nederlands, auf deutsch, is het gratis, kannst du das wiederholen |
Uitsluitingen: een query die op minstens de helft van de dagen binnen zijn actieve periode terugkomt, minstens 30 dagen, en voor 80% of meer desktop is, telt niet mee (vaste zoekzin of tracker). Kale ja-woorden vallen nooit onder die regel. "wat met X" en "hoe zit het met X" met een eigen onderwerp zijn in het Vlaams een gewone zoekvorm en tellen nooit mee. De Duitse regex vraagt "wie sieht es mit" of "was ist mit", nooit kaal "wie mit". Pas dat niet terug aan.
## Stap 3: controleer voor je rapporteert
- **Sjabloonfamilies:** bij een kennisbank of FAQ kan een familie ook een echte reeks vragen zijn. Kijk of ze mobiel verkeer heeft.
- **B2:** vangt ook foutmeldingen en losse zinnen in een andere taal. Lees de grootste tien na.
- **Groep C** beweegt het eindcijfer het meest. Noem ze apart en zeg dat het bewijs indirect is.
- **Groep A1:** de eigen promptmeting van de gebruiker vervuilt zijn eigen Search Console een beetje. Benoem dat.
- **Kleine sites:** onder ongeveer 300 lange queries rapporteer je aantallen, geen percentages.
- **Losse woorden** als "ja", "si", "ok" kunnen ook merk, afkorting of gewone zoekopdracht zijn. Kijk in Query x URL waar ze landen: op onderwerpspecifieke pagina's op positie 2 tot 5 past dat bij een vervolgbeurt, op een homepage of merkpagina ben je voorzichtig.
- **Trend voor de lancering:** staat het korte spoor al maanden voor de lancering van AI Mode in die markt boven nul, dan zit er nog een gewone zoekvorm in. Lees de tab Per query na.
- **Laatste maand** is bijna altijd onvolledig. Noem hem, trek er geen conclusie uit.
- **Per URL:** Search Console telt impressies per pagina, dus de som per URL ligt hoger dan de som per query. Vergelijk die totalen niet.
## Ervaringswaarden uit eerdere toepassingen
Steekproeven op B2B- en dienstensites in België en Duitsland (2025-2026). Gebruik ze als verwachting, niet als norm.
- Toestel is het sterkste signaal: zekere trackergroepen waren voor 98 tot 100% desktop en kregen geen kliks.
- Commerciële lange queries (kiezen, vergelijken, prijs, reviews) haalden samen een derde van de lange-query-impressies en nauwelijks kliks.
- Lange queries waren minder dan 1% van alle impressies; na correctie bleef 85 tot 90% van die impressies over. De trackerruis zat geconcentreerd op enkele pagina's.
- Het korte spoor groeide van rond 1 per miljoen impressies (najaar 2025) naar 100 tot 250 per miljoen (zomer en najaar 2026), met 35 tot 65% mobiel. Bobbink zag in september 2026 ongeveer 116 per miljoen over 155 sites.
- In meertalige markten vragen mensen de AI expliciet om van taal te wisselen ("in het nederlands", "et pour la belgique").
- Kale "ja" landde vaak op informatieve blogs, terwijl de kliks naar vacature-, product- of contactpagina's gingen.
## Rapporteren
- Lang spoor: begin met het eindcijfer, het aandeel impressies dat naar schatting niet van een tracker komt.
- Kort spoor: begin met de trend per miljoen impressies (eerste volle maanden tegenover laatste volle maanden) en het aandeel mobiel. Bij meerdere sites: de mediaan per site, niet het gepoolde totaal.
- URL-koppeling: de pagina's met het meeste trackeraandeel, de pagina's die het vaakst op een AI Mode-spoor verschijnen, en naar welk paginatype de kliks gaan.
- Scheid waargenomen (query, pagina, kliks, impressies, positie, datum, toestel) van afgeleid (groep, kans, vorm, taal, intentie, kandidaten, tracker-impressies per URL, paginatype).
- Noem kansen en vormen altijd schattingen.
- Vaste beperkingen: Search Console verbergt zeldzame queries, dus unieke prompts en de meeste vervolgvragen van echte mensen ontbreken; de herhalingstest werkt alleen binnen de periode van de dagdata; een tracker die een mobiel toestel nabootst, valt niet op; recente dagen staan te laag; interface-exports stoppen bij 1.000 rijen.
- Lever de Excel-bestanden als bestand aan de gebruiker. Geef URL's voluit.
Gemaakt door Depends (depends.be). Vrij te gebruiken en aan te passen.
gsc-prompt-detectie/scripts/ai_mode_spoor.py13,7 kB
#!/usr/bin/env python3
"""Kort spoor: zoekt AI Mode-sporen (antwoorden aan een AI, vervolgvragen, gespreksvragen) in korte Search Console-queries
en schrijft een Excel-werkboek met een trend per miljoen impressies, plus een JSON met de indeling per query.
--kort: query, clicks, impressions, liefst ook date en device (export met de regex uit GSC_REGEX).
--totaal: date, impressions zonder queryfilter (noemer voor per miljoen). Optioneel.
--kort-datums: tab Datums van de gefilterde interface-export, als --kort geen datum heeft. Optioneel."""
import argparse, collections, datetime as dt, json, re, sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gsc_io import load, num, device, periode
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
JA = {'ja': 'nl/de', 'jawel': 'nl', 'yes': 'en', 'yeah': 'en', 'yep': 'en', 'yup': 'en', 'sure': 'en',
'oui': 'fr', 'si': 'es/it', 'sí': 'es', 'ok': '-', 'okay': '-', 'oké': 'nl', 'okee': 'nl',
'graag': 'nl', 'gerne': 'de', 'bitte': 'de', 'please': 'en', 'svp': 'fr', 'stp': 'fr',
'doe maar': 'nl', 'go ahead': 'en', 'continue': 'en', 'weiter': 'de', 'verder': 'nl', 'ga door': 'nl',
'ga verder': 'nl', 'mach weiter': 'de', 'meer': 'nl', 'mehr': 'de', 'more': 'en', "d'accord": 'fr',
'vas-y': 'fr', 'klopt': 'nl', 'genau': 'de', 'stimmt': 'de', 'danke': 'de', 'dank je': 'nl',
'dankjewel': 'nl', 'bedankt': 'nl', 'merci': 'fr', 'thanks': 'en', 'thank you': 'en', 'perfect': '-', 'super': '-'}
BELEEFD = re.compile(r"\b(bitte|gerne|graag|alsjeblieft|alstublieft|aub|please|svp|stp|s.il (te|vous) pla[iî]t|merci|danke|bedankt|thanks|thank you)\b")
JA_WOORD = re.compile(r"^(ja|jawel|yes|yeah|yep|yup|oui|si|sí|sure)\b")
VERVOLG = re.compile(r"^(what|how) about |^and (for|what about|if|in) |^(en|und|et) (voor|für|pour|wat met|was ist mit|als|wenn|si) |^(wat|hoe) (met|zit het met|dan met) |^(und )?was ist (denn )?mit |^wie sieht es (denn )?mit |^(und )?wie wäre es mit |^qu.en est-il ")
GESPREK = re.compile(r"^(in|auf|en|op|in het) (het )?(nederlands|engels|frans|duits|deutsch|englisch|französisch|english|dutch|german|french|français|anglais|néerlandais|allemand)\b|^(give me|geef me|gib mir|donne-moi|show me|toon me|zeig mir|montre-moi|tell me|vertel me) (more|meer|mehr|plus|an example|een voorbeeld|ein beispiel|un exemple|another|nog)|^(can|could) you (do|make|give|show|explain|translate|write|summari[sz]e) (it|that|this|me)|^(kun|kan|kunt) (je|jij|u) (dat|dit|het|me|mij) |^(kannst|könntest) du (das|es|mir) |^(peux|pourrais)-tu |^(is it|is het|ist es|ist das|est-ce) (free|gratis|kostenlos|gratuit|good|goed|gut|bon|worth it)")
# "wat met X" en "hoe zit het met X" met een eigen onderwerp zijn in het Vlaams een gewone zoekvorm, geen vervolgbeurt.
WATMET = re.compile(r"^(wat|hoe) (met|zit het met|dan met) \S+")
# Filter voor Search Console (RE2). Print met: python3 ai_mode_spoor.py --regex
GSC_REGEX = r"(?i)^(ja|jawel|yes|yeah|yep|yup|oui|si|sí|ok|okay|oké|okee|sure|graag|gerne|bitte|please|svp|stp|doe maar|go ahead|continue|weiter|verder|ga door|ga verder|mach weiter|meer|mehr|more|d'accord|vas-y|klopt|genau|stimmt|danke|dank je|dankjewel|bedankt|merci|thanks|thank you|perfect|super)([ ,.!]+(ja|yes|oui|graag|gerne|bitte|please|svp|stp|s.il (te|vous) pla[iî]t|alsjeblieft|alstublieft|aub|doe maar|danke|thanks|thank you|bedankt|merci|do it|go ahead|mach das|zeig mir|toon maar|super|top|perfect))*[ .!?]*$|" + VERVOLG.pattern + "|" + GESPREK.pattern
VORMEN = ['Antwoord aan een AI', 'Vervolgvraag', 'Gespreksvraag']
def norm(q):
return re.sub(r'[ .!?,]+$', '', q.lower().strip())
def vorm(q):
n = norm(q)
if VERVOLG.search(n + ' '): return 'Vervolgvraag'
if GESPREK.search(n + ' ') or GESPREK.search(n): return 'Gespreksvraag'
eerste = re.split(r'[ ,.!]+', n)
if n in JA or (eerste and eerste[0] in JA): return 'Antwoord aan een AI'
return 'Overig'
def taal(q):
n = norm(q); w = set(re.split(r'[ ,.!?]+', n))
for t, s in (('nl', {'graag', 'aub', 'alsjeblieft', 'jawel', 'doe', 'meer', 'het', 'engels', 'nederlands', 'frans', 'kan', 'kun', 'dit', 'wat'}),
('de', {'bitte', 'gerne', 'deutsch', 'mehr', 'kannst', 'du', 'auf', 'ist', 'mit'}),
('fr', {'oui', 'svp', 'merci', 'français', 'pour', 'est-ce', 'peux-tu', "d'accord", 'gratuit'}),
('en', {'yes', 'please', 'what', 'how', 'about', 'english', 'tell', 'it', 'free'})):
if w & s: return t
return JA.get(n, '?')
def main():
if '--regex' in sys.argv:
print(GSC_REGEX); return
ap = argparse.ArgumentParser()
ap.add_argument('--kort', required=True); ap.add_argument('--totaal'); ap.add_argument('--kort-datums')
ap.add_argument('--label', required=True); ap.add_argument('--out', required=True)
ap.add_argument('--max-woorden', type=int, default=7)
ap.add_argument('--min-dagdekking', type=float, default=0.5); ap.add_argument('--min-dagen-patroon', type=int, default=30)
a = ap.parse_args()
K, mk = load(a.kort)
K = [r for r in K if r.get('query') and len(r['query'].split()) <= a.max_woorden]
heeft_dag = all(r.get('date') for r in K) and bool(K); heeft_dev = all(r.get('device') for r in K) and bool(K)
per_q = collections.defaultdict(lambda: {'kliks': 0, 'imp': 0, 'dagen': set(), 'dev': collections.Counter()})
for r in K:
d = per_q[r['query'].strip()]; i = int(num(r['impressions']))
d['kliks'] += int(num(r.get('clicks'))); d['imp'] += i
if heeft_dag: d['dagen'].add(r['date'])
if heeft_dev: d['dev'][device(r['device'])] += i
def patroon(q):
d = per_q[q]; ds = sorted(d['dagen'])
if not heeft_dag or not heeft_dev or len(ds) < a.min_dagen_patroon: return ''
span = (dt.date.fromisoformat(ds[-1]) - dt.date.fromisoformat(ds[0])).days + 1
dekking = len(ds) / span; desk = d['dev']['desktop'] / max(1, d['imp'])
if dekking >= a.min_dagdekking and desk >= 0.8 and norm(q) not in JA and not JA_WOORD.match(norm(q)):
return f'Vast patroon: {len(ds)} dagen op {span} ({dekking:.0%}), {desk:.0%} desktop'
return ''
rijen = []
for q, d in per_q.items():
v = vorm(q); p = patroon(q)
if not p and v == 'Vervolgvraag' and WATMET.match(norm(q)):
p = 'Gewone Vlaamse zoekvorm: "wat met / hoe zit het met" plus een eigen onderwerp'
mob = d['dev']['mobile'] + d['dev']['tablet']
rijen.append({'q': q, 'vorm': v, 'taal': taal(q), 'kliks': d['kliks'], 'imp': d['imp'], 'dagen': len(d['dagen']) if heeft_dag else None,
'eerst': min(d['dagen']) if d['dagen'] else '', 'laatst': max(d['dagen']) if d['dagen'] else '',
'desk': d['dev']['desktop'] if heeft_dev else None, 'mob': mob if heeft_dev else None,
'beleefd': ('ja' if BELEEFD.search(norm(q)) else 'nee') if v == 'Antwoord aan een AI' else '',
'telt': 'nee' if (p or v == 'Overig') else 'ja', 'reden': p or ('Geen AI Mode-vorm' if v == 'Overig' else '')})
rijen.sort(key=lambda r: -r['imp'])
telt = {r['q']: r for r in rijen if r['telt'] == 'ja'}
tot_m = collections.Counter()
if a.totaal:
for r in load(a.totaal)[0]:
if r.get('date'): tot_m[r['date'][:7]] += int(num(r['impressions']))
ai_m = collections.Counter(); vorm_m = collections.defaultdict(collections.Counter); trendbron = 'geen datum beschikbaar'
if heeft_dag:
trendbron = 'per query per dag, alleen queries die meetellen'
for r in K:
if r['query'].strip() in telt:
m = r['date'][:7]; i = int(num(r['impressions'])); ai_m[m] += i; vorm_m[telt[r['query'].strip()]['vorm']][m] += i
elif a.kort_datums:
trendbron = 'tab Datums van de gefilterde export: NIET gecorrigeerd voor uitgesloten queries'
for r in load(a.kort_datums)[0]:
if r.get('date'): ai_m[r['date'][:7]] += int(num(r['impressions']))
maanden = sorted(set(ai_m) | set(tot_m))
F = Font(name='Arial', size=10); B = Font(name='Arial', size=10, bold=True); H = Font(name='Arial', size=10, bold=True, color='FFFFFF')
fill = PatternFill('solid', fgColor='1F3A5F'); wrap = Alignment(wrap_text=True, vertical='top')
def head(ws, cols, widths, row=1):
for k, (c, w) in enumerate(zip(cols, widths)):
x = ws.cell(row, k + 1, c); x.font = H; x.fill = fill; x.alignment = wrap; ws.column_dimensions[x.column_letter].width = w
wb = Workbook(); ws = wb.active; ws.title = 'Samenvatting'
ws['A1'] = f'AI Mode-sporen in korte queries: {a.label}'; ws['A1'].font = Font(name='Arial', size=12, bold=True)
ws['A2'] = f'Vormen en uitsluitingen zijn afgeleid. Trend: {trendbron}. Toestel: {"ja" if heeft_dev else "niet beschikbaar"}.'; ws['A2'].font = F
head(ws, ['Vorm', 'Queries', 'Impressies', 'Kliks', 'CTR', 'Impressies mobiel en tablet', 'Aandeel mobiel en tablet'], [44, 10, 12, 8, 8, 14, 14], 4)
som = {'n': 0, 'i': 0, 'k': 0, 'm': 0}
for v in VORMEN:
rr = [r for r in telt.values() if r['vorm'] == v]; i = sum(r['imp'] for r in rr); k = sum(r['kliks'] for r in rr); m = sum(r['mob'] or 0 for r in rr)
ws.append([v, len(rr), i, k, k / i if i else 0, m if heeft_dev else '', (m / i if i else 0) if heeft_dev else ''])
som['n'] += len(rr); som['i'] += i; som['k'] += k; som['m'] += m
ws.append(['Totaal (telt mee)', som['n'], som['i'], som['k'], som['k'] / som['i'] if som['i'] else 0, som['m'] if heeft_dev else '', (som['m'] / som['i'] if som['i'] else 0) if heeft_dev else ''])
uit = [r for r in rijen if r['telt'] == 'nee' and r['reden'] != 'Geen AI Mode-vorm']
ws.append(['Uitgesloten: vast patroon of gewone zoekvorm', len(uit), sum(r['imp'] for r in uit)])
ja = [r for r in telt.values() if r['vorm'] == 'Antwoord aan een AI']; ja_i = sum(r['imp'] for r in ja)
ws.append(['Ja-antwoorden met een beleefdheidswoord (aandeel impressies)', '', (sum(r['imp'] for r in ja if r['beleefd'] == 'ja') / ja_i) if ja_i else 0])
for row in ws.iter_rows(min_row=5):
for x in row: x.font = F
row[4].number_format = '0.0%'
if len(row) > 6: row[6].number_format = '0.0%'
ws.cell(8, 1).font = B; ws.cell(ws.max_row, 3).number_format = '0%'
w1 = wb.create_sheet('Per query')
head(w1, ['Query', 'Vorm (afgeleid)', 'Taal (afgeleid)', 'Kliks', 'Impressies', 'Dagen gezien', 'Eerste dag', 'Laatste dag', 'Impressies desktop', 'Impressies mobiel en tablet', 'Beleefdheidswoord (afgeleid)', 'Telt mee', 'Reden uitsluiting'], [40, 22, 8, 7, 11, 10, 11, 11, 11, 12, 12, 9, 55])
for r in rijen:
w1.append([r['q'], r['vorm'], r['taal'], r['kliks'], r['imp'], r['dagen'], r['eerst'], r['laatst'], r['desk'], r['mob'], r['beleefd'], r['telt'], r['reden']])
for row in w1.iter_rows(min_row=2):
for x in row: x.font = F
w1.freeze_panes = 'A2'; w1.auto_filter.ref = f'A1:M{len(rijen) + 1}'
w2 = wb.create_sheet('Per maand')
head(w2, ['Maand', 'Alle impressies (land)', 'Impressies AI Mode-sporen', 'Per miljoen impressies'] + [f'{v} per miljoen' for v in VORMEN], [10, 16, 16, 14, 16, 16, 16])
for m in maanden:
t = tot_m[m]
w2.append([m, t or '', ai_m[m], ai_m[m] / t * 1e6 if t else ''] + [(vorm_m[v][m] / t * 1e6 if t else '') if heeft_dag else '' for v in VORMEN])
for row in w2.iter_rows(min_row=2):
for x in row: x.font = F
for x in row[3:]: x.number_format = '0.0'
bron = f"Google Search Console, {a.label}, {periode(mk)}, gefilterd op AI Mode-vormen, queries tot {a.max_woorden} woorden."
w3 = wb.create_sheet('Toelichting'); w3.column_dimensions['A'].width = 28; w3.column_dimensions['B'].width = 120
for k, v in [('Bron', bron),
('Waargenomen', 'Query, kliks, impressies, en waar aanwezig datum en toestel, komen rechtstreeks uit Search Console. Totale impressies per dag ook.'),
('Afgeleid', 'Vorm, taal, beleefdheidswoord en uitsluitingen volgen uit tekstregels en drempels. Search Console labelt geen AI Mode-verkeer.'),
('Waarom dit spoor', 'Google telt een vervolgvraag in AI Mode als nieuwe query. Een kale "ja graag" of "en als student" heeft alleen betekenis als volgende beurt in een gesprek. Methode naar Jan-Willem Bobbink (GSC Wizard, oktober 2026): https://www.gscwizard.com/learn/research/ai-mode-queries-in-search-console.html'),
('Vast patroon', 'Een query die op een groot deel van de dagen binnen zijn actieve periode terugkomt en vooral desktop is, telt niet mee. Vraagt dag- en toesteldata. Kale ja-woorden vallen nooit onder deze regel.'),
('Vlaamse zoekvorm', '"wat met X" en "hoe zit het met X" met een eigen onderwerp tellen niet mee. Kale vervolgvormen ("en als student", "en voor 2026") wel.'),
('Beperking', 'Losse woorden zoals "ja" of "si" kunnen ook merk, afkorting of gewone zoekopdracht zijn. De trend weegt zwaarder dan een losse query. Search Console verbergt zeldzame queries. Recente dagen staan iets te laag omdat Google nog rijen toevoegt.'),
('Regex (GSC-filter)', GSC_REGEX)]:
w3.append([k, v])
for r in w3.iter_rows(): r[0].font = B; r[1].font = F; r[1].alignment = wrap
wb.save(a.out)
json.dump({'label': a.label, 'queries': {r['q']: {'vorm': r['vorm'], 'telt': r['telt']} for r in rijen}}, open(a.out + '.json', 'w', encoding='utf-8'), ensure_ascii=False)
print(json.dumps({'queries': len(rijen), 'telt_mee': len(telt), 'impressies_telt_mee': som['i'], 'aandeel_mobiel': round(som['m'] / som['i'], 3) if heeft_dev and som['i'] else None,
'trendbron': trendbron, 'uitgesloten': [(r['q'], r['imp'], r['reden']) for r in uit],
'per_maand': {m: {'ai': ai_m[m], 'per_miljoen': round(ai_m[m] / tot_m[m] * 1e6, 1) if tot_m[m] else None} for m in maanden}}, ensure_ascii=False, indent=1))
if __name__ == '__main__':
main()
gsc-prompt-detectie/scripts/gsc_io.py3 kB
"""Gedeelde inleesfuncties: leest Search Console-exports (CSV, TSV, XLSX of JSON met een veld data) en normaliseert kolomnamen."""
import csv, io, json, os
ALIAS = {
'query': ['query', 'queries', 'top queries', 'populairste zoekopdrachten', 'zoekopdracht', 'zoekopdrachten', 'keyword', 'zoekterm'],
'page': ['page', 'pages', 'top pages', "populairste pagina's", 'pagina', "pagina's", 'url', 'address', 'landing page'],
'clicks': ['clicks', 'klikken', 'kliks', 'url clicks'],
'impressions': ['impressions', 'vertoningen', 'impressies'],
'position': ['position', 'positie', 'average position', 'gemiddelde positie', 'avg. position'],
'ctr': ['ctr', 'url ctr'],
'date': ['date', 'datum', 'dag', 'day'],
'device': ['device', 'apparaat', 'toestel', 'devices', 'apparaten'],
'country': ['country', 'land', 'landen', 'countries'],
}
LOOKUP = {a: k for k, v in ALIAS.items() for a in v}
DEVICE = {'desktop': 'desktop', 'computer': 'desktop', 'mobile': 'mobile', 'mobiel': 'mobile', 'tablet': 'tablet'}
def _rows_from_text(raw):
first = raw.split('\n', 1)[0]
delim = '\t' if '\t' in first else (';' if first.count(';') > first.count(',') else ',')
return list(csv.reader(io.StringIO(raw), delimiter=delim, quoting=csv.QUOTE_NONE if delim == '\t' else csv.QUOTE_MINIMAL))
def load(path):
"""Geeft (rijen als dicts met genormaliseerde sleutels, meta). path mag eindigen op #Tabblad voor een XLSX."""
meta, sheet = {}, None
if '#' in path and path.lower().split('#')[0].endswith('.xlsx'):
path, sheet = path.split('#', 1)
if path.lower().endswith('.xlsx'):
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb[sheet] if sheet else wb.worksheets[0]
rows = [['' if c is None else str(c) for c in r] for r in ws.iter_rows(values_only=True)]
else:
raw = open(path, encoding='utf-8-sig').read()
if raw.lstrip().startswith('{'):
meta = json.loads(raw); raw = meta.pop('data')
rows = _rows_from_text(raw)
rows = [r for r in rows if any(x.strip() for x in r)]
head = [LOOKUP.get(h.strip().lower(), h.strip().lower()) for h in rows[0]]
out = [dict(zip(head, r)) for r in rows[1:] if len(r) >= len(head)]
meta.setdefault('bestand', os.path.basename(path))
return out, meta
def num(x):
"""Zet '1234', '3,4', '12%' of '' om naar een getal."""
s = str(x or '').strip().replace('%', '').replace(' ', '').replace(' ', '')
if not s:
return 0.0
if ',' in s and '.' not in s:
s = s.replace(',', '.')
elif ',' in s and '.' in s:
s = s.replace('.', '').replace(',', '.') if s.rfind(',') > s.rfind('.') else s.replace(',', '')
try:
return float(s)
except ValueError:
return 0.0
def device(x):
return DEVICE.get(str(x).strip().lower(), str(x).strip().lower())
def periode(meta):
return f"{meta.get('start_date', '?')} t/m {meta.get('end_date', '?')}" if meta.get('start_date') else meta.get('bestand', '?')
gsc-prompt-detectie/scripts/prompt_detectie.py14,4 kB
#!/usr/bin/env python3
"""Lang spoor: deelt lange Search Console-queries in naar kans op herkomst uit een tracker
(prompttrackers, AI-agents, fan-outs) en schrijft een Excel-werkboek plus een JSON met de indeling per query.
Minimaal: --queries (query, clicks, impressions[, position]).
Beter: --daily (date, query, impressions) voor de herhalingstest en
--device (query, device, impressions) of --desktop en --mobiel (twee exports met een toestelfilter)."""
import argparse, collections, json, re, sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gsc_io import load, num, device, periode
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
norm = lambda s: re.sub(r'[^\w ]', '', s.lower()).strip()
INSTRUCTIE = re.compile(r"my location is|recherchiere online|suche online|nenne mir konkrete|fasse .* zusammen|vous devez fournir|classe-les|classement forc|du meilleur au pire|zoek online|noem concrete|geef een (lijst|overzicht|rangschikking)|search online|search the web|rank them|list specific|provide a (list|ranking|table)|cite (your )?sources|answer in \w+$|antworte (auf|in) ")
PERSONA = re.compile(r"^(ich bin |ich leite |ich arbeite |ich führe |ich suche |wir sind |wir haben |wir suchen |wir wollen |wir möchten |als \w+ (einer|eines|von) |unsere? \w+ |je suis |nous sommes |en tant qu|ik ben |ik werk |ik zoek |wij zijn |we zijn |als \w+ van |onze? \w+ |i am |i'm |i work |i run |we are |we're |as an? |our \w+ )")
VRAAG = re.compile(r"^(welche[rsn]?|wie|was|warum|wann|wo|sollte|gibt es|kann|ist|ich|wir|als|unsere?|nenne|finde|zeig|erkläre|erstelle|suche|how|what|which|why|when|where|can|is|are|should|do|does|i|we|wat|welke?|hoe|waarom|wanneer|waar|kan|is|moet|ik|wij|quel(le)?s?|comment|pourquoi|quand|où|est-ce|peut-on|je|nous)\b")
STOP = {'de': 'der die das und ist für mit ein eine welche wie was nicht auf von zu im bei oder kann ich wir sich den dem'.split(),
'nl': 'de het een en is voor met welke hoe wat niet op van te in bij of kan ik wij je zijn naar mijn'.split(),
'fr': 'le la les et est pour avec un une quel quelle comment que ne pas sur de du des dans ou je nous vous au aux'.split(),
'en': 'the and is for with a an which how what not on of to in at or can i we you my your are do does'.split()}
GROEPEN = [ # naam, (kans: te weinig of geen toesteldata, alleen desktop, gemengd), regel
('A1 Eigen trackerprompt', (.99, .99, .99), 'Letterlijk gelijk aan een prompt uit de eigen promptlijst (leestekens genegeerd)'),
('A2 Instructie aan een AI', (.95, .95, .95), 'Bevat een opdracht aan een AI, zoals "my location is", "recherchiere online", "rank them"'),
('A3 Sjabloon met invulvakken', (.95, .97, .80), 'Familie van queries met dezelfde opening en hetzelfde slot, 0 kliks in de hele familie'),
('B1 Persona-prompt', (.85, .92, .60), 'Opent met een rol of situatie ("ik werk", "wir sind", "je suis", "as a") en telt 10+ woorden'),
('B2 Taal wijkt af van de markt', (.80, .85, .60), 'Promptvorm in een taal die niet bij de opgegeven markt hoort'),
('B3 Lange zin die terugkomt', (.80, .90, .50), '12+ woorden, eindigt op leesteken, op 5+ dagen exact herhaald, 0 kliks (vraagt dagdata)'),
('C Vraagzin op 20+ dagen', (.60, .85, .35), 'Volledige zin met leesteken, op 20+ dagen herhaald, 0 kliks (vraagt dagdata)'),
('D Zin, weinig herhaald', (.30, .70, .15), 'Zin met leesteken die op minder dan 20 dagen voorkomt, of zonder dagdata'),
('E Lange query zonder leesteken', (.20, .70, .10), '10+ woorden, geen leesteken, geen ander signaal'),
('F Zoekwoord-achtig', (.05, .70, .03), 'Hooguit 9 woorden, geen leesteken')]
PROFIEL = ['Te weinig of geen toesteldata', 'Alleen desktop', 'Gemengd']
INTENTIE = [('Alternatief / vergelijking', r'\bvs\.?\b|versus|alternativ|vergleich|vergelijk|comparaison|compare|classement|unterschied zwischen|verschil tussen|difference between|statt |in plaats van|instead of'),
('Prijs / kosten', r'kosten|preis|prijs|tarif|prix|pricing|price|günstig|goedkoop|cheap|betaalbaar|kostenlos|gratis|free'),
('Aanbieder kiezen', r'\bbeste[nrs]?\b|\bbest\b|welche[rs]? .*(software|tool|anbieter|lösung)|welke .*(software|tool|aanbieder|oplossing|platform|bedrijf)|which .*(software|tool|provider|platform|company)|meilleure?s?|empfehl|aanbevel|recommend'),
('Reviews / ervaringen', r'erfahrung|bewert|vor- und nachteile|ervaring|review|voor- en nadelen|pros and cons|avis'),
('Hoe doe ik / probleem', r'^wie |^how |^hoe |^comment |hoeveel|how (do|can|long|much)|wie viel|darf |muss |mag |moet '),
('Definitie / uitleg', r'^was ist|^what is|^what are|^wat is|^wat zijn|bedeutung|betekenis|definition|definitie|explain|leg uit|qu.est-ce')]
def taal(q):
w = re.findall(r"[\w'äöüßéèêàç]+", q.lower())
sc = {k: sum(1 for x in w if x in v) for k, v in STOP.items()}
best = max(sc, key=sc.get)
return best if sc[best] >= 2 and list(sc.values()).count(sc[best]) == 1 else ''
def promptvorm(q):
return bool(re.search(r'[?.!]$', q)) or (len(q.split()) >= 10 and bool(VRAAG.search(q)))
def sjablonen(queries, clicks):
fam = collections.defaultdict(set)
for q in queries:
w = re.sub(r'\d+', '#', q).split()
if len(w) < 8: continue
fam[('ps', ' '.join(w[:4]), ' '.join(w[-3:]))].add(q)
fam[('p', ' '.join(w[:6]))].add(q)
fam[('s', ' '.join(w[-6:]))].add(q)
out = {}
for k, v in fam.items():
if len(v) >= (5 if k[0] == 'ps' else 8) and sum(clicks[q] for q in v) == 0:
for q in v: out.setdefault(q, ' ... '.join(k[1:]))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--queries', required=True); ap.add_argument('--daily'); ap.add_argument('--device')
ap.add_argument('--desktop', help='export met toestelfilter desktop (query, impressions)'); ap.add_argument('--mobiel', help='export met toestelfilter mobiel (en eventueel tablet)')
ap.add_argument('--prompts'); ap.add_argument('--talen', default=''); ap.add_argument('--label', required=True); ap.add_argument('--out', required=True)
ap.add_argument('--min-woorden', type=int, default=7); ap.add_argument('--min-impr', type=int, default=30); ap.add_argument('--max-mobiel', type=float, default=0.02)
a = ap.parse_args()
Qr, mq = load(a.queries)
Q = [(r['query'].strip(), int(num(r.get('clicks'))), int(num(r.get('impressions'))), num(r.get('position'))) for r in Qr if r.get('query')]
voor = len(Q); Q = [r for r in Q if len(r[0].split()) >= a.min_woorden]
dagen = collections.defaultdict(set); heeft_dag = bool(a.daily)
if a.daily:
for r in load(a.daily)[0]: dagen[r['query'].strip()].add(r['date'])
dev = collections.defaultdict(collections.Counter); heeft_dev = bool(a.device or a.desktop or a.mobiel)
if a.device:
for r in load(a.device)[0]: dev[r['query'].strip()][device(r['device'])] += int(num(r['impressions']))
for pad, d in ((a.desktop, 'desktop'), (a.mobiel, 'mobile')):
if pad:
for r in load(pad)[0]: dev[r['query'].strip()][d] += int(num(r['impressions']))
eigen = {norm(l) for l in open(a.prompts, encoding='utf-8')} - {''} if a.prompts else set()
talen = {t.strip() for t in a.talen.split(',') if t.strip()}
klik = {q: c for q, c, _, _ in Q}
sj = sjablonen([q for q, *_ in Q], klik)
def groep(q, c):
n = len(q.split()); nd = len(dagen.get(q, ())); zin = bool(re.search(r'[?.!]$', q))
if norm(q) in eigen: return 0
if INSTRUCTIE.search(q): return 1
if q in sj: return 2
if PERSONA.search(q) and n >= 10: return 3
if talen and promptvorm(q) and taal(q) and taal(q) not in talen: return 4
if n >= 12 and nd >= 5 and c == 0 and zin: return 5
if zin and nd >= 20 and c == 0: return 6
if zin: return 7
return 9 if n <= 9 else 8
def profiel(q, i):
mb = dev[q]['mobile'] + dev[q]['tablet']; tot = mb + dev[q]['desktop']
if not heeft_dev or tot < a.min_impr: return 0, (mb / tot if tot else None)
return (1 if mb / tot <= a.max_mobiel else 2), mb / tot
def intentie(q):
for naam, p in INTENTIE:
if re.search(p, q): return naam
return 'Overig'
def kandidaat(q, c, i, g, pr, aandeel):
if g <= 5 or not promptvorm(q): return ''
if c > 0: return 'Sterk: klik'
if pr == 2: return 'Sterk: gemengd toestel'
if pr == 0 and dev[q]['mobile'] + dev[q]['tablet'] > 0: return 'Zwak: mobiele impressie, weinig data'
return ''
res = []
for q, c, i, p in sorted(Q, key=lambda r: -r[2]):
g = groep(q, c); pr, aandeel = profiel(q, i); kans = GROEPEN[g][1][pr]
res.append({'query': q, 'kliks': c, 'impressies': i, 'positie': p, 'woorden': len(q.split()), 'dagen': len(dagen.get(q, ())) if heeft_dag else None,
'desktop': dev[q]['desktop'], 'mobiel_tablet': dev[q]['mobile'] + dev[q]['tablet'], 'aandeel_mobiel': aandeel,
'profiel': PROFIEL[pr], 'groep': GROEPEN[g][0], 'kans': kans, 'na_correctie': i * (1 - kans),
'intentie': intentie(q), 'kandidaat': kandidaat(q, c, i, g, pr, aandeel), 'sjabloon': sj.get(q, '')})
F = Font(name='Arial', size=10); B = Font(name='Arial', size=10, bold=True); H = Font(name='Arial', size=10, bold=True, color='FFFFFF')
fill = PatternFill('solid', fgColor='1F3A5F'); wrap = Alignment(wrap_text=True, vertical='top')
def head(ws, cols, widths, row=1):
for k, (c, w) in enumerate(zip(cols, widths)):
x = ws.cell(row, k + 1, c); x.font = H; x.fill = fill; x.alignment = wrap; ws.column_dimensions[x.column_letter].width = w
tot_i = sum(r['impressies'] for r in res); tot_na = sum(r['na_correctie'] for r in res)
wb = Workbook(); ws = wb.active; ws.title = 'Samenvatting'
ws['A1'] = f'Lange queries {a.label}: kans op herkomst uit een tracker'; ws['A1'].font = Font(name='Arial', size=12, bold=True)
ws['A2'] = ('Kansen zijn schattingen, niet gemeten. Herhalingstest: ' + ('ja' if heeft_dag else 'niet beschikbaar (geen dagdata)') +
'. Toesteltest: ' + ('ja' if heeft_dev else 'niet beschikbaar (geen toesteldata)') + '.'); ws['A2'].font = F
head(ws, ['Groep'] + [f'Kans: {p}' for p in PROFIEL] + ['Queries', 'Impressies', 'Kliks', 'Impressies na correctie', 'Aandeel impressies', 'Regel'], [34, 14, 12, 12, 9, 12, 8, 14, 12, 90], 4)
for k, (g, kans, regel) in enumerate(GROEPEN):
rr = [r for r in res if r['groep'] == g]; i = sum(r['impressies'] for r in rr)
ws.append([g, *kans, len(rr), i, sum(r['kliks'] for r in rr), round(sum(r['na_correctie'] for r in rr)), i / tot_i if tot_i else 0, regel])
ws.append(['Totaal', '', '', '', len(res), tot_i, sum(r['kliks'] for r in res), round(tot_na), 1 if tot_i else 0, ''])
ws.append(['Aandeel impressies dat overblijft na correctie', '', '', '', '', '', '', tot_na / tot_i if tot_i else 0])
for row in ws.iter_rows(min_row=5):
for x in row: x.font = F
for x in row[1:4]: x.number_format = '0%'
row[8].number_format = '0.0%'
for x in ws[ws.max_row - 1] + ws[ws.max_row]: x.font = B
ws.cell(ws.max_row, 8).number_format = '0.0%'
ws.freeze_panes = 'A5'
w1 = wb.create_sheet('Queries')
cols = ['Query', 'Kliks', 'Impressies', 'Gem. positie', 'Aantal woorden', 'Dagen gezien', 'Impressies desktop', 'Impressies mobiel en tablet', 'Aandeel mobiel en tablet',
'Toestelprofiel', 'Groep (afgeleid)', 'Kans tracker (schatting)', 'Impressies na correctie', 'Intentie (afgeleid)', 'Kandidaat natuurlijke prompt (afgeleid)', 'Sjabloonfamilie']
head(w1, cols, [90, 8, 11, 10, 9, 9, 11, 11, 11, 22, 30, 11, 13, 24, 32, 45])
keys = ['query', 'kliks', 'impressies', 'positie', 'woorden', 'dagen', 'desktop', 'mobiel_tablet', 'aandeel_mobiel', 'profiel', 'groep', 'kans', 'na_correctie', 'intentie', 'kandidaat', 'sjabloon']
for r in res: w1.append([r[k] for k in keys])
for row in w1.iter_rows(min_row=2):
for x in row: x.font = F
row[8].number_format = row[11].number_format = '0%'; row[12].number_format = '#,##0.0'
w1.freeze_panes = 'A2'; w1.auto_filter.ref = f'A1:P{len(res) + 1}'
if a.daily:
w2 = wb.create_sheet('Per dag'); head(w2, ['Datum', 'Query', 'Impressies'], [12, 100, 12])
for r in sorted(load(a.daily)[0], key=lambda r: (r['date'], r['query'])): w2.append([r['date'], r['query'], int(num(r['impressions']))])
bron = f"Google Search Console, {a.label}, {periode(mq)}, {voor} rijen ingelezen, {len(Q)} met {a.min_woorden}+ woorden."
w3 = wb.create_sheet('Toelichting'); w3.column_dimensions['A'].width = 28; w3.column_dimensions['B'].width = 120
for k, v in [('Bron', bron),
('Waargenomen', 'Query, kliks, impressies, positie, en waar aanwezig datum en impressies per toestel, komen rechtstreeks uit Search Console.'),
('Afgeleid', 'Groep, kans, intentie, sjabloonfamilie en kandidaat volgen uit tekstregels en drempels. Een query krijgt de eerste groep waarvan de regel past.'),
('Toestelprofiel', f'Alleen desktop = aandeel mobiel en tablet hooguit {a.max_mobiel:.0%}. Gemengd = erboven. Te weinig of geen toesteldata = minder dan {a.min_impr} impressies met toesteldata.'),
('Impressies na correctie', 'Impressies x (1 - kans tracker).'),
('Tracker', 'Alles wat machinaal prompts afvuurt: prompttrackers, AI-agents en fan-outs van AI-systemen.'),
('Beperking', 'Search Console verbergt zeldzame queries, dus unieke prompts van echte gebruikers ontbreken grotendeels. Interface-exports tonen maximaal 1.000 rijen. De herhalingstest werkt alleen binnen de periode van de dagdata. Een tracker die een mobiel toestel nabootst, valt niet op.')]:
w3.append([k, v])
for r in w3.iter_rows(): r[0].font = B; r[1].font = F; r[1].alignment = wrap
wb.save(a.out)
json.dump({'label': a.label, 'queries': {r['query']: {k: r[k] for k in ('groep', 'kans', 'profiel', 'kandidaat')} for r in res}},
open(a.out + '.json', 'w', encoding='utf-8'), ensure_ascii=False)
print(json.dumps({'queries': len(res), 'impressies': tot_i, 'kliks': sum(r['kliks'] for r in res), 'aandeel_na_correctie': round(tot_na / tot_i, 3) if tot_i else None,
'herhalingstest': heeft_dag, 'toesteltest': heeft_dev,
'groepen': {g: [sum(1 for r in res if r['groep'] == g), sum(r['impressies'] for r in res if r['groep'] == g)] for g, _, _ in GROEPEN},
'kandidaten': sum(1 for r in res if r['kandidaat']), 'sjabloonfamilies': len(set(sj.values()))}, ensure_ascii=False, indent=1))
if __name__ == '__main__':
main()
gsc-prompt-detectie/scripts/url_koppeling.py7 kB
#!/usr/bin/env python3
"""Koppelt beide sporen aan landingspagina's: voegt tabs 'Per URL' en 'Query x URL' toe aan de werkboeken.
Leest de indeling per query uit het JSON-bestand dat naast elk werkboek staat (werkboek.xlsx.json).
Vraagt een export query x pagina (query, page, clicks, impressions, position)."""
import argparse, collections, json, re, sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gsc_io import load, num, periode
from urllib.parse import urlparse
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
F = Font(name='Arial', size=10); B = Font(name='Arial', size=10, bold=True); H = Font(name='Arial', size=10, bold=True, color='FFFFFF')
fill = PatternFill('solid', fgColor='1F3A5F'); wrap = Alignment(wrap_text=True, vertical='top')
def head(ws, cols, widths):
for k, (c, w) in enumerate(zip(cols, widths)):
x = ws.cell(1, k + 1, c); x.font = H; x.fill = fill; x.alignment = wrap; ws.column_dimensions[x.column_letter].width = w
ws.freeze_panes = 'A2'
def finish(ws, pct=()):
for row in ws.iter_rows(min_row=2):
for x in row: x.font = F
for c in pct: row[c].number_format = '0%'
ws.auto_filter.ref = f'A1:{ws.cell(1, ws.max_column).column_letter}{ws.max_row}'
def paginatype(u):
"""Eerste padsegment na een eventuele taalmap: /nl/blog/... wordt blog."""
seg = [s for s in urlparse(u).path.split('/') if s]
if seg and re.fullmatch(r'[a-z]{2}(-[a-z]{2})?', seg[0]): seg = seg[1:]
return seg[0] if seg else 'home'
def voorbereid(wbpad):
wb = openpyxl.load_workbook(wbpad)
for n in ('Per URL', 'Query x URL'):
if n in wb.sheetnames: del wb[n]
t = wb['Toelichting']
for r in range(t.max_row, 0, -1):
if t.cell(r, 1).value == 'Koppeling aan URL': t.delete_rows(r)
return wb
def noteer(wb, tekst):
t = wb['Toelichting']; t.append(['Koppeling aan URL', tekst])
t.cell(t.max_row, 1).font = B; t.cell(t.max_row, 2).font = F; t.cell(t.max_row, 2).alignment = wrap
def rijen(qp):
R, meta = load(qp)
return sorted([r for r in R if r.get('query') and r.get('page')], key=lambda r: -num(r['impressions'])), meta
def lang(wbpad, qp):
cls = json.load(open(wbpad + '.json', encoding='utf-8'))['queries']
R, meta = rijen(qp); wb = voorbereid(wbpad)
w = wb.create_sheet('Query x URL', 2)
head(w, ['Query', 'URL', 'Kliks', 'Impressies', 'Gem. positie', 'Groep (afgeleid)', 'Toestelprofiel query', 'Kans tracker (schatting)', 'Tracker-impressies (geschat)', 'Kandidaat natuurlijke prompt'], [80, 70, 7, 11, 10, 30, 22, 11, 14, 30])
agg = collections.defaultdict(lambda: {'q': 0, 'i': 0, 'k': 0, 'tr': 0.0, 'a1': 0, 'a2': 0, 'top': collections.Counter(), 'kand': 0})
for r in R:
c = cls.get(r['query'].strip())
if not c: continue
i = int(num(r['impressions'])); k = int(num(r.get('clicks'))); tr = i * c['kans']
w.append([r['query'], r['page'], k, i, num(r.get('position')), c['groep'], c['profiel'], c['kans'], round(tr, 1), c['kandidaat']])
a = agg[r['page']]; a['q'] += 1; a['i'] += i; a['k'] += k; a['tr'] += tr
if c['groep'].startswith('A1'): a['a1'] += i
if c['groep'].startswith('A2'): a['a2'] += i
if c['kans'] >= 0.5: a['top'][r['query']] += i
if c['kandidaat']: a['kand'] += 1
finish(w, (7,))
p = wb.create_sheet('Per URL', 1)
head(p, ['URL', 'Paginatype', 'Lange queries', 'Impressies', 'Kliks', 'Tracker-impressies (geschat)', 'Aandeel tracker (geschat)', 'Impressies eigen trackerprompts (A1)', 'Impressies met AI-instructie (A2)', 'Kandidaten natuurlijke prompt', 'Grootste tracker-query (kans 50%+)'], [70, 14, 10, 11, 7, 14, 12, 14, 14, 12, 80])
L = sorted(agg.items(), key=lambda x: -x[1]['tr'])
for u, a in L:
top = a['top'].most_common(1)
p.append([u, paginatype(u), a['q'], a['i'], a['k'], round(a['tr'], 1), a['tr'] / a['i'] if a['i'] else 0, a['a1'], a['a2'], a['kand'], top[0][0] if top else ''])
finish(p, (6,))
noteer(wb, f"Tabs 'Per URL' en 'Query x URL' komen uit een export query x pagina ({periode(meta)}). Search Console telt impressies per pagina, dus de som per URL ligt hoger dan de som per query. Tracker-impressies = impressies x kans tracker van de query (afgeleid).")
wb.save(wbpad)
return {'urls': len(L), 'tracker_geschat': round(sum(a['tr'] for a in agg.values())),
'top_tracker': [(u, a['q'], a['i'], round(a['tr']), round(a['tr'] / a['i'], 2) if a['i'] else 0, a['a1']) for u, a in L[:15]]}
def kort(wbpad, qp):
cls = json.load(open(wbpad + '.json', encoding='utf-8'))['queries']
R, meta = rijen(qp); wb = voorbereid(wbpad)
w = wb.create_sheet('Query x URL', 2)
head(w, ['Query', 'URL', 'Paginatype', 'Kliks', 'Impressies', 'Gem. positie', 'Vorm (afgeleid)', 'Telt mee'], [34, 80, 14, 7, 11, 10, 22, 9])
agg = collections.defaultdict(lambda: {'q': set(), 'i': 0, 'k': 0, 'pos': 0.0, 'top': collections.Counter(), 'v': collections.Counter()})
for r in R:
q = r['query'].strip(); c = cls.get(q, {'vorm': '?', 'telt': 'nee'})
i = int(num(r['impressions'])); k = int(num(r.get('clicks'))); pos = num(r.get('position'))
w.append([q, r['page'], paginatype(r['page']), k, i, pos, c['vorm'], c['telt']])
if c['telt'] == 'ja':
a = agg[r['page']]; a['q'].add(q); a['i'] += i; a['k'] += k; a['pos'] += i * pos; a['top'][q] += i; a['v'][c['vorm']] += i
finish(w)
p = wb.create_sheet('Per URL', 1)
head(p, ['URL', 'Paginatype', 'AI Mode-queries', 'Impressies', 'Kliks', 'Gem. positie (gewogen)', 'Antwoorden aan een AI', 'Vervolgvragen', 'Gespreksvragen', 'Grootste query'], [80, 14, 10, 11, 7, 12, 12, 12, 12, 24])
L = sorted(agg.items(), key=lambda x: -x[1]['i'])
for u, a in L:
p.append([u, paginatype(u), len(a['q']), a['i'], a['k'], round(a['pos'] / a['i'], 1) if a['i'] else '', a['v']['Antwoord aan een AI'], a['v']['Vervolgvraag'], a['v']['Gespreksvraag'], a['top'].most_common(1)[0][0]])
finish(p)
noteer(wb, f"Tabs 'Per URL' en 'Query x URL' komen uit een export query x pagina ({periode(meta)}). Een pagina die op een kale vervolgbeurt ('ja', 'graag') verschijnt, is de pagina die Google bij dat AI Mode-gesprek toont. Alleen queries met Telt mee = ja tellen in Per URL.")
wb.save(wbpad)
tt = collections.Counter(); tk = collections.Counter()
for u, a in agg.items(): tt[paginatype(u)] += a['i']; tk[paginatype(u)] += a['k']
return {'urls': len(L), 'per_paginatype': {t: {'impressies': tt[t], 'kliks': tk[t]} for t in sorted(tt, key=lambda x: -tt[x])},
'top_ai_mode': [(u, len(a['q']), a['i'], a['k'], a['top'].most_common(1)[0][0]) for u, a in L[:15]]}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--lang-wb'); ap.add_argument('--lang-qp'); ap.add_argument('--kort-wb'); ap.add_argument('--kort-qp')
a = ap.parse_args(); out = {}
if a.lang_wb and a.lang_qp: out['lang'] = lang(a.lang_wb, a.lang_qp)
if a.kort_wb and a.kort_qp: out['kort'] = kort(a.kort_wb, a.kort_qp)
print(json.dumps(out, ensure_ascii=False, indent=1))
if __name__ == '__main__':
main()
Installeren
Zo zet je hem in Claude
In Claude.ai of Claude Desktop
- Download
gsc-prompt-detectie.zip. Pak hem niet uit. - Ga naar Instellingen › Capabilities en zet code-uitvoering aan als dat nog niet zo is.
- Kies bij Skills voor Upload skill en selecteer de zip.
- Start een nieuw gesprek. Claude gebruikt de skill vanzelf zodra je vraag erbij past.
In Claude Code
- Download
gsc-prompt-detectie.zip. - Pak hem uit in je persoonlijke skillmap, of in
.claude/skills/van een project om hem met je team te delen:
unzip gsc-prompt-detectie.zip -d ~/.claude/skills/Start Claude Code opnieuw. Met /skills zie je of hij geladen is.
Andere skills
Alle skillsNulmeting AI-zichtbaarheid
Meet hoe vaak ChatGPT, Gemini en andere AI-zoekmachines je merk noemen, op welke plaats en op basis van welke bronnen.
Claude.ai · Claude CodeBekijk ContentSEO-blogschrijver
Van zoekwoord naar contentbrief en daarna een blogartikel dat rankt in Google en geciteerd wordt door AI.
Claude.ai · Claude CodeBekijk ContentAI-schrijfpatronen
Vindt en schrapt de typische AI-tics in Nederlandse en Engelse teksten, met een aparte modus voor SEO-audits.
Claude.ai · Claude CodeBekijkWil je dat we het samen met je team opzetten?
Plan een gesprek van 30 minuten met Mathias, de oprichter van Depends.
Plan een gesprek