mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 09:33:19 +03:00
Filtering Search Console keywords (#4077)
* Apply filters in search console request * Remove dead code from search console modal * Remove unimportant information from keyword modal * Show invalid filters from search console * Fix tests * Add/Fix tests * Fix typo * Remove unused variable * Fix typo * Changelog entry * Fix Credo * Display impressions, CTR and position in keyword modal * Undo change that should not have been committed * Fix test * Fix test * filters -> search_console_filters
This commit is contained in:
parent
39cf8c4179
commit
06e8118dab
@ -42,6 +42,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Add Yesterday as an time range option in the dashboard
|
- Add Yesterday as an time range option in the dashboard
|
||||||
- Add support for importing Google Analytics 4 data
|
- Add support for importing Google Analytics 4 data
|
||||||
- Import custom events from Google Analytics 4
|
- Import custom events from Google Analytics 4
|
||||||
|
- Ability to filter Search Console keywords by page, country and device plausible/analytics#4077
|
||||||
- Add `DATA_DIR` env var for exports/imports plausible/analytics#4100
|
- Add `DATA_DIR` env var for exports/imports plausible/analytics#4100
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
@ -4,7 +4,7 @@ import { withRouter } from 'react-router-dom'
|
|||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import numberFormatter from '../../util/number-formatter'
|
import numberFormatter, {percentageFormatter} from '../../util/number-formatter'
|
||||||
import { parseQuery } from '../../query'
|
import { parseQuery } from '../../query'
|
||||||
import { trimURL } from '../../util/url'
|
import { trimURL } from '../../util/url'
|
||||||
class ExitPagesModal extends React.Component {
|
class ExitPagesModal extends React.Component {
|
||||||
@ -34,14 +34,6 @@ class ExitPagesModal extends React.Component {
|
|||||||
this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this))
|
this.setState({ loading: true, page: this.state.page + 1 }, this.loadPages.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
formatPercentage(number) {
|
|
||||||
if (typeof (number) === 'number') {
|
|
||||||
return number + '%'
|
|
||||||
} else {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showConversionRate() {
|
showConversionRate() {
|
||||||
return !!this.state.query.filters.goal
|
return !!this.state.query.filters.goal
|
||||||
}
|
}
|
||||||
@ -74,7 +66,7 @@ class ExitPagesModal extends React.Component {
|
|||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.total_visitors)}</td>}
|
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.total_visitors)}</td>}
|
||||||
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
|
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visitors)}</td>
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visits)}</td>}
|
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.visits)}</td>}
|
||||||
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatPercentage(page.exit_rate)}</td>}
|
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{percentageFormatter(page.exit_rate)}</td>}
|
||||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
|
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.conversion_rate)}%</td>}
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
@ -3,7 +3,7 @@ import { Link, withRouter } from 'react-router-dom'
|
|||||||
|
|
||||||
import Modal from './modal'
|
import Modal from './modal'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import numberFormatter from '../../util/number-formatter'
|
import numberFormatter, { percentageFormatter } from '../../util/number-formatter'
|
||||||
import {parseQuery} from '../../query'
|
import {parseQuery} from '../../query'
|
||||||
import RocketIcon from './rocket-icon'
|
import RocketIcon from './rocket-icon'
|
||||||
|
|
||||||
@ -17,49 +17,31 @@ class GoogleKeywordsModal extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.state.query.filters.goal) {
|
|
||||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/goal/referrers/Google`, this.state.query, {limit: 100})
|
|
||||||
.then((res) => this.setState({
|
|
||||||
loading: false,
|
|
||||||
searchTerms: res.search_terms,
|
|
||||||
totalVisitors: res.total_visitors,
|
|
||||||
notConfigured: res.not_configured,
|
|
||||||
isOwner: res.is_owner
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/Google`, this.state.query, {limit: 100})
|
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/Google`, this.state.query, {limit: 100})
|
||||||
.then((res) => this.setState({
|
.then((res) => this.setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
searchTerms: res.search_terms,
|
searchTerms: res.search_terms,
|
||||||
totalVisitors: res.total_visitors,
|
|
||||||
notConfigured: res.not_configured,
|
notConfigured: res.not_configured,
|
||||||
isOwner: res.is_owner
|
isOwner: res.is_owner
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
renderTerm(term) {
|
renderTerm(term) {
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={term.name}>
|
<React.Fragment key={term.name}>
|
||||||
|
|
||||||
<tr className="text-sm dark:text-gray-200" key={term.name}>
|
<tr className="text-sm dark:text-gray-200" key={term.name}>
|
||||||
<td className="p-2 truncate">{term.name}</td>
|
<td className="p-2">{term.name}</td>
|
||||||
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(term.visitors)}</td>
|
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(term.visitors)}</td>
|
||||||
|
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(term.impressions)}</td>
|
||||||
|
<td className="p-2 w-32 font-medium" align="right">{percentageFormatter(term.ctr)}</td>
|
||||||
|
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(term.position)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderKeywords() {
|
renderKeywords() {
|
||||||
if (this.state.query.filters.goal) {
|
if (this.state.notConfigured) {
|
||||||
return (
|
|
||||||
<div className="text-center text-gray-700 dark:text-gray-300 mt-6">
|
|
||||||
<RocketIcon />
|
|
||||||
<div className="text-lg">Sorry, we cannot show which keywords converted best for goal <b>{this.state.query.filters.goal}</b></div>
|
|
||||||
<div className="text-lg">Google does not share this information</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} else if (this.state.notConfigured) {
|
|
||||||
if (this.state.isOwner) {
|
if (this.state.isOwner) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-gray-700 dark:text-gray-300 mt-6">
|
<div className="text-center text-gray-700 dark:text-gray-300 mt-6">
|
||||||
@ -84,7 +66,10 @@ class GoogleKeywordsModal extends React.Component {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Search Term</th>
|
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Search Term</th>
|
||||||
<th className="p-2 w-32 lg:w-1/2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visitors</th>
|
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visitors</th>
|
||||||
|
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Impressions</th>
|
||||||
|
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CTR</th>
|
||||||
|
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Position</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -102,14 +87,6 @@ class GoogleKeywordsModal extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGoalText() {
|
|
||||||
if (this.state.query.filters.goal) {
|
|
||||||
return (
|
|
||||||
<h1 className="text-xl font-semibold text-gray-500 dark:text-gray-200 leading-none">completed {this.state.query.filters.goal}</h1>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBody() {
|
renderBody() {
|
||||||
if (this.state.loading) {
|
if (this.state.loading) {
|
||||||
return (
|
return (
|
||||||
@ -122,10 +99,6 @@ class GoogleKeywordsModal extends React.Component {
|
|||||||
|
|
||||||
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
||||||
<main className="modal__content">
|
<main className="modal__content">
|
||||||
<h1 className="text-xl font-semibold mb-0 leading-none dark:text-gray-200">
|
|
||||||
{this.state.totalVisitors} visitors from Google<br />
|
|
||||||
</h1>
|
|
||||||
{this.renderGoalText()}
|
|
||||||
{ this.renderKeywords() }
|
{ this.renderKeywords() }
|
||||||
</main>
|
</main>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
@ -39,7 +39,8 @@ export default class SearchTerms extends React.Component {
|
|||||||
loading: false,
|
loading: false,
|
||||||
searchTerms: res.search_terms || [],
|
searchTerms: res.search_terms || [],
|
||||||
notConfigured: res.not_configured,
|
notConfigured: res.not_configured,
|
||||||
isAdmin: res.is_admin
|
isAdmin: res.is_admin,
|
||||||
|
unsupportedFilters: res.unsupported_filters
|
||||||
})).catch((error) =>
|
})).catch((error) =>
|
||||||
{
|
{
|
||||||
this.setState({ loading: false, searchTerms: [], notConfigured: true, error: true, isAdmin: error.payload.is_admin })
|
this.setState({ loading: false, searchTerms: [], notConfigured: true, error: true, isAdmin: error.payload.is_admin })
|
||||||
@ -68,21 +69,19 @@ export default class SearchTerms extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderList() {
|
renderList() {
|
||||||
if (this.props.query.filters.goal) {
|
if (this.state.unsupportedFilters) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
|
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
|
||||||
<RocketIcon />
|
<RocketIcon />
|
||||||
<div>Sorry, we cannot show which keywords converted best for goal <b>{this.props.query.filters.goal}</b></div>
|
<div>Unable to fetch keyword data from Search Console because it does not support the current set of filters</div>
|
||||||
<div>Google does not share this information</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
} else if (this.state.notConfigured) {
|
} else if (this.state.notConfigured) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
|
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
|
||||||
<RocketIcon />
|
<RocketIcon />
|
||||||
<div>
|
<div>
|
||||||
This site is not connected to Search Console so we cannot show the search phrases.
|
This site is not connected to Search Console so we cannot show the search terms
|
||||||
{this.state.isAdmin && this.state.error && <><br/><br/><p>Please click below to connect your Search Console account.</p></>}
|
{this.state.isAdmin && this.state.error && <><br/><br/><p>Please click below to connect your Search Console account.</p></>}
|
||||||
</div>
|
</div>
|
||||||
{this.state.isAdmin && <a href={`/${encodeURIComponent(this.props.site.domain)}/settings/integrations`} className="button mt-4">Connect with Google</a> }
|
{this.state.isAdmin && <a href={`/${encodeURIComponent(this.props.site.domain)}/settings/integrations`} className="button mt-4">Connect with Google</a> }
|
||||||
@ -103,9 +102,8 @@ export default class SearchTerms extends React.Component {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
|
<div className="text-center text-gray-700 dark:text-gray-300 ">
|
||||||
<RocketIcon />
|
<div className="mt-44 mx-auto font-medium text-gray-500 dark:text-gray-400">No data yet</div>
|
||||||
<div>No search terms were found for this period. Please adjust or extend your time range. Check <a href="https://plausible.io/docs/google-search-console-integration#i-dont-see-google-search-query-data-in-my-dashboard" target="_blank" rel="noreferrer" className="hover:underline text-indigo-700 dark:text-indigo-500">our documentation</a> for more details.</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -49,3 +49,11 @@ export function durationFormatter(duration) {
|
|||||||
return `${seconds}s`
|
return `${seconds}s`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function percentageFormatter(number) {
|
||||||
|
if (typeof (number) === 'number') {
|
||||||
|
return number + '%'
|
||||||
|
} else {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"status": 200,
|
|
||||||
"url": "https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Adummy.test/searchAnalytics/query",
|
|
||||||
"method": "post",
|
|
||||||
"request_body": {
|
|
||||||
"dimensionFilterGroups": {},
|
|
||||||
"dimensions": [
|
|
||||||
"query"
|
|
||||||
],
|
|
||||||
"endDate": "2022-01-05",
|
|
||||||
"rowLimit": 5,
|
|
||||||
"startDate": "2022-01-01"
|
|
||||||
},
|
|
||||||
"response_body": {
|
|
||||||
"responseAggregationType": "auto",
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"clicks": 25.0,
|
|
||||||
"ctr": 0.3,
|
|
||||||
"impressions": 50.0,
|
|
||||||
"keys": [
|
|
||||||
"keyword1",
|
|
||||||
"keyword2"
|
|
||||||
],
|
|
||||||
"position": 2.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"clicks": 15.0,
|
|
||||||
"ctr": 0.5,
|
|
||||||
"impressions": 25.0,
|
|
||||||
"keys": [
|
|
||||||
"keyword3",
|
|
||||||
"keyword4"
|
|
||||||
],
|
|
||||||
"position": 4.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
@ -4,7 +4,7 @@
|
|||||||
"url": "https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Adummy.test/searchAnalytics/query",
|
"url": "https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Adummy.test/searchAnalytics/query",
|
||||||
"method": "post",
|
"method": "post",
|
||||||
"request_body": {
|
"request_body": {
|
||||||
"dimensionFilterGroups": {},
|
"dimensionFilterGroups": [],
|
||||||
"dimensions": [
|
"dimensions": [
|
||||||
"query"
|
"query"
|
||||||
],
|
],
|
||||||
@ -16,23 +16,17 @@
|
|||||||
"responseAggregationType": "auto",
|
"responseAggregationType": "auto",
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
"clicks": 25.0,
|
"clicks": 25,
|
||||||
"ctr": 0.3,
|
"ctr": 0.3679,
|
||||||
"impressions": 50.0,
|
"impressions": 50,
|
||||||
"keys": [
|
"keys": ["keyword1"],
|
||||||
"keyword1",
|
"position": 2.2312312
|
||||||
"keyword2"
|
|
||||||
],
|
|
||||||
"position": 2.0
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"clicks": 15.0,
|
"clicks": 15,
|
||||||
"ctr": 0.5,
|
"ctr": 0.5,
|
||||||
"impressions": 25.0,
|
"impressions": 25,
|
||||||
"keys": [
|
"keys": ["keyword3"],
|
||||||
"keyword3",
|
|
||||||
"keyword4"
|
|
||||||
],
|
|
||||||
"position": 4.0
|
"position": 4.0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -6,6 +6,7 @@ defmodule Plausible.Google.API do
|
|||||||
use Timex
|
use Timex
|
||||||
|
|
||||||
alias Plausible.Google.HTTP
|
alias Plausible.Google.HTTP
|
||||||
|
alias Plausible.Google.SearchConsole
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@ -74,21 +75,26 @@ defmodule Plausible.Google.API do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fetch_stats(site, %{filters: %{} = filters, date_range: date_range}, limit) do
|
def fetch_stats(site, %{filters: %{} = filters, date_range: date_range}, limit) do
|
||||||
with site <- Plausible.Repo.preload(site, :google_auth),
|
with {:ok, site} <- ensure_search_console_property(site),
|
||||||
{:ok, access_token} <- maybe_refresh_token(site.google_auth),
|
{:ok, access_token} <- maybe_refresh_token(site.google_auth),
|
||||||
|
{:ok, search_console_filters} <-
|
||||||
|
SearchConsole.Filters.transform(site.google_auth.property, filters),
|
||||||
{:ok, stats} <-
|
{:ok, stats} <-
|
||||||
HTTP.list_stats(
|
HTTP.list_stats(
|
||||||
access_token,
|
access_token,
|
||||||
site.google_auth.property,
|
site.google_auth.property,
|
||||||
date_range,
|
date_range,
|
||||||
limit,
|
limit,
|
||||||
filters["page"]
|
search_console_filters
|
||||||
) do
|
) do
|
||||||
stats
|
stats
|
||||||
|> Map.get("rows", [])
|
|> Map.get("rows", [])
|
||||||
|> Enum.filter(fn row -> row["clicks"] > 0 end)
|
|> Enum.map(&search_console_row/1)
|
||||||
|> Enum.map(fn row -> %{name: row["keys"], visitors: round(row["clicks"])} end)
|
|
||||||
|> then(&{:ok, &1})
|
|> then(&{:ok, &1})
|
||||||
|
else
|
||||||
|
:google_property_not_configured -> {:error, :google_property_not_configured}
|
||||||
|
:unsupported_filters -> {:error, :unsupported_filters}
|
||||||
|
{:error, error} -> {:error, error}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -142,6 +148,44 @@ defmodule Plausible.Google.API do
|
|||||||
Timex.before?(expires_at, thirty_seconds_ago)
|
Timex.before?(expires_at, thirty_seconds_ago)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp ensure_search_console_property(site) do
|
||||||
|
site = Plausible.Repo.preload(site, :google_auth)
|
||||||
|
|
||||||
|
if site.google_auth && site.google_auth.property do
|
||||||
|
{:ok, site}
|
||||||
|
else
|
||||||
|
:google_property_not_configured
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp search_console_row(row) do
|
||||||
|
%{
|
||||||
|
# We always request just one dimension at a time (`query`)
|
||||||
|
name: row["keys"] |> List.first(),
|
||||||
|
visitors: row["clicks"],
|
||||||
|
impressions: row["impressions"],
|
||||||
|
ctr: rounded_ctr(row["ctr"]),
|
||||||
|
position: rounded_position(row["position"])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp rounded_ctr(ctr) do
|
||||||
|
{:ok, decimal} = Decimal.cast(ctr)
|
||||||
|
|
||||||
|
decimal
|
||||||
|
|> Decimal.mult(100)
|
||||||
|
|> Decimal.round(1)
|
||||||
|
|> Decimal.to_float()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp rounded_position(position) do
|
||||||
|
{:ok, decimal} = Decimal.cast(position)
|
||||||
|
|
||||||
|
decimal
|
||||||
|
|> Decimal.round(1)
|
||||||
|
|> Decimal.to_float()
|
||||||
|
end
|
||||||
|
|
||||||
defp client_id() do
|
defp client_id() do
|
||||||
Keyword.fetch!(Application.get_env(:plausible, :google), :client_id)
|
Keyword.fetch!(Application.get_env(:plausible, :google), :client_id)
|
||||||
end
|
end
|
||||||
|
@ -39,26 +39,18 @@ defmodule Plausible.Google.HTTP do
|
|||||||
response.body
|
response.body
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_stats(access_token, property, date_range, limit, page \\ nil) do
|
def list_stats(access_token, property, date_range, limit, search_console_filters) do
|
||||||
property = URI.encode_www_form(property)
|
|
||||||
|
|
||||||
filter_groups =
|
|
||||||
if page do
|
|
||||||
url = property_base_url(property)
|
|
||||||
[%{filters: [%{dimension: "page", expression: "https://#{url}#{page}"}]}]
|
|
||||||
else
|
|
||||||
%{}
|
|
||||||
end
|
|
||||||
|
|
||||||
params = %{
|
params = %{
|
||||||
startDate: Date.to_iso8601(date_range.first),
|
startDate: Date.to_iso8601(date_range.first),
|
||||||
endDate: Date.to_iso8601(date_range.last),
|
endDate: Date.to_iso8601(date_range.last),
|
||||||
dimensions: ["query"],
|
dimensions: ["query"],
|
||||||
rowLimit: limit,
|
rowLimit: limit,
|
||||||
dimensionFilterGroups: filter_groups
|
dimensionFilterGroups: search_console_filters
|
||||||
}
|
}
|
||||||
|
|
||||||
url = "#{api_url()}/webmasters/v3/sites/#{property}/searchAnalytics/query"
|
url =
|
||||||
|
"#{api_url()}/webmasters/v3/sites/#{URI.encode_www_form(property)}/searchAnalytics/query"
|
||||||
|
|
||||||
headers = [{"Authorization", "Bearer #{access_token}"}]
|
headers = [{"Authorization", "Bearer #{access_token}"}]
|
||||||
|
|
||||||
case HTTPClient.impl().post(url, headers, params) do
|
case HTTPClient.impl().post(url, headers, params) do
|
||||||
@ -78,9 +70,6 @@ defmodule Plausible.Google.HTTP do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp property_base_url("sc-domain:" <> domain), do: "https://" <> domain
|
|
||||||
defp property_base_url(url), do: url
|
|
||||||
|
|
||||||
def refresh_auth_token(refresh_token) do
|
def refresh_auth_token(refresh_token) do
|
||||||
url = "#{api_url()}/oauth2/v4/token"
|
url = "#{api_url()}/oauth2/v4/token"
|
||||||
headers = [{"content-type", "application/x-www-form-urlencoded"}]
|
headers = [{"content-type", "application/x-www-form-urlencoded"}]
|
||||||
|
83
lib/plausible/google/search_console/filters.ex
Normal file
83
lib/plausible/google/search_console/filters.ex
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
defmodule Plausible.Google.SearchConsole.Filters do
|
||||||
|
@moduledoc false
|
||||||
|
import Plausible.Stats.Base, only: [page_regex: 1]
|
||||||
|
|
||||||
|
def transform(property, plausible_filters) do
|
||||||
|
plausible_filters = Map.drop(plausible_filters, ["visit:source"])
|
||||||
|
|
||||||
|
search_console_filters =
|
||||||
|
Enum.reduce_while(plausible_filters, [], fn plausible_filter, search_console_filters ->
|
||||||
|
case transform_filter(property, plausible_filter) do
|
||||||
|
:unsupported -> {:halt, :unsupported_filters}
|
||||||
|
search_console_filter -> {:cont, [search_console_filter | search_console_filters]}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case search_console_filters do
|
||||||
|
:unsupported_filters -> :unsupported_filters
|
||||||
|
[] -> {:ok, []}
|
||||||
|
filters when is_list(filters) -> {:ok, [%{filters: filters}]}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(property, {"event:page", filter}) do
|
||||||
|
transform_filter(property, {"visit:entry_page", filter})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(property, {"visit:entry_page", {:is, page}}) when is_binary(page) do
|
||||||
|
%{dimension: "page", expression: property_url(property, page)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(property, {"visit:entry_page", {:member, pages}}) when is_list(pages) do
|
||||||
|
expression =
|
||||||
|
Enum.map_join(pages, "|", fn page -> property_url(property, Regex.escape(page)) end)
|
||||||
|
|
||||||
|
%{dimension: "page", operator: "includingRegex", expression: expression}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(property, {"visit:entry_page", {:matches, page}}) when is_binary(page) do
|
||||||
|
page = page_regex(property_url(property, page))
|
||||||
|
%{dimension: "page", operator: "includingRegex", expression: page}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(property, {"visit:entry_page", {:matches_member, pages}})
|
||||||
|
when is_list(pages) do
|
||||||
|
expression =
|
||||||
|
Enum.map_join(pages, "|", fn page -> page_regex(property_url(property, page)) end)
|
||||||
|
|
||||||
|
%{dimension: "page", operator: "includingRegex", expression: expression}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(_property, {"visit:screen", {:is, device}}) when is_binary(device) do
|
||||||
|
%{dimension: "device", expression: search_console_device(device)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(_property, {"visit:screen", {:member, devices}}) when is_list(devices) do
|
||||||
|
expression = devices |> Enum.join("|")
|
||||||
|
%{dimension: "device", operator: "includingRegex", expression: expression}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(_property, {"visit:country", {:is, country}}) when is_binary(country) do
|
||||||
|
%{dimension: "country", expression: search_console_country(country)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(_property, {"visit:country", {:member, countries}})
|
||||||
|
when is_list(countries) do
|
||||||
|
expression = Enum.map_join(countries, "|", &search_console_country/1)
|
||||||
|
%{dimension: "country", operator: "includingRegex", expression: expression}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_filter(_, _filter), do: :unsupported
|
||||||
|
|
||||||
|
defp property_url("sc-domain:" <> domain, page), do: "https://" <> domain <> page
|
||||||
|
defp property_url(url, page), do: url <> page
|
||||||
|
|
||||||
|
defp search_console_device("Desktop"), do: "DESKTOP"
|
||||||
|
defp search_console_device("Mobile"), do: "MOBILE"
|
||||||
|
defp search_console_device("Tablet"), do: "TABLET"
|
||||||
|
|
||||||
|
defp search_console_country(alpha_2) do
|
||||||
|
country = Location.Country.get_country(alpha_2)
|
||||||
|
country.alpha_3
|
||||||
|
end
|
||||||
|
end
|
@ -680,36 +680,29 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def referrer_drilldown(conn, %{"referrer" => "Google"} = params) do
|
def referrer_drilldown(conn, %{"referrer" => "Google"} = params) do
|
||||||
site = conn.assigns[:site] |> Repo.preload(:google_auth)
|
site = conn.assigns[:site]
|
||||||
|
|
||||||
query =
|
query = Query.from(site, params)
|
||||||
Query.from(site, params)
|
|
||||||
|> Query.put_filter("visit:source", "Google")
|
|
||||||
|
|
||||||
search_terms =
|
|
||||||
if site.google_auth && site.google_auth.property && !query.filters["goal"] do
|
|
||||||
google_api().fetch_stats(site, query, params["limit"] || 9)
|
|
||||||
end
|
|
||||||
|
|
||||||
%{:visitors => %{value: total_visitors}} = Stats.aggregate(site, query, [:visitors])
|
|
||||||
|
|
||||||
user_id = get_session(conn, :current_user_id)
|
user_id = get_session(conn, :current_user_id)
|
||||||
is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site)
|
is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site)
|
||||||
|
|
||||||
case search_terms do
|
case google_api().fetch_stats(site, query, params["limit"] || 9) do
|
||||||
nil ->
|
{:error, :google_propery_not_configured} ->
|
||||||
json(conn, %{not_configured: true, is_admin: is_admin, total_visitors: total_visitors})
|
json(conn, %{not_configured: true, is_admin: is_admin})
|
||||||
|
|
||||||
|
{:error, :unsupported_filters} ->
|
||||||
|
json(conn, %{unsupported_filters: true})
|
||||||
|
|
||||||
{:ok, terms} ->
|
{:ok, terms} ->
|
||||||
json(conn, %{search_terms: terms, total_visitors: total_visitors})
|
json(conn, %{search_terms: terms})
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
conn
|
conn
|
||||||
|> put_status(502)
|
|> put_status(502)
|
||||||
|> json(%{
|
|> json(%{
|
||||||
not_configured: true,
|
not_configured: true,
|
||||||
is_admin: is_admin,
|
is_admin: is_admin
|
||||||
total_visitors: total_visitors
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -245,7 +245,7 @@ defmodule Plausible.Google.APITest do
|
|||||||
"https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Adummy.test/searchAnalytics/query",
|
"https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Adummy.test/searchAnalytics/query",
|
||||||
[{"Authorization", "Bearer 123"}],
|
[{"Authorization", "Bearer 123"}],
|
||||||
%{
|
%{
|
||||||
dimensionFilterGroups: %{},
|
dimensionFilterGroups: [],
|
||||||
dimensions: ["query"],
|
dimensions: ["query"],
|
||||||
endDate: "2022-01-05",
|
endDate: "2022-01-05",
|
||||||
rowLimit: 5,
|
rowLimit: 5,
|
||||||
@ -301,67 +301,6 @@ defmodule Plausible.Google.APITest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "fetch_stats/3 with VCR cassetes" do
|
|
||||||
test "returns name and visitor count", %{user: user, site: site} do
|
|
||||||
mock_http_with("google_analytics_stats.json")
|
|
||||||
|
|
||||||
insert(:google_auth,
|
|
||||||
user: user,
|
|
||||||
site: site,
|
|
||||||
property: "sc-domain:dummy.test",
|
|
||||||
expires: NaiveDateTime.add(NaiveDateTime.utc_now(), 3600)
|
|
||||||
)
|
|
||||||
|
|
||||||
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
|
|
||||||
|
|
||||||
assert {:ok,
|
|
||||||
[
|
|
||||||
%{name: ["keyword1", "keyword2"], visitors: 25},
|
|
||||||
%{name: ["keyword3", "keyword4"], visitors: 15}
|
|
||||||
]} = Google.API.fetch_stats(site, query, 5)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns next page when page argument is set", %{user: user, site: site} do
|
|
||||||
mock_http_with("google_analytics_stats#with_page.json")
|
|
||||||
|
|
||||||
insert(:google_auth,
|
|
||||||
user: user,
|
|
||||||
site: site,
|
|
||||||
property: "sc-domain:dummy.test",
|
|
||||||
expires: NaiveDateTime.add(NaiveDateTime.utc_now(), 3600)
|
|
||||||
)
|
|
||||||
|
|
||||||
query = %Plausible.Stats.Query{
|
|
||||||
filters: %{"page" => 5},
|
|
||||||
date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert {:ok,
|
|
||||||
[
|
|
||||||
%{name: ["keyword1", "keyword2"], visitors: 25},
|
|
||||||
%{name: ["keyword3", "keyword4"], visitors: 15}
|
|
||||||
]} = Google.API.fetch_stats(site, query, 5)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "defaults first page when page argument is not set", %{user: user, site: site} do
|
|
||||||
mock_http_with("google_analytics_stats#without_page.json")
|
|
||||||
|
|
||||||
insert(:google_auth,
|
|
||||||
user: user,
|
|
||||||
site: site,
|
|
||||||
property: "sc-domain:dummy.test",
|
|
||||||
expires: NaiveDateTime.add(NaiveDateTime.utc_now(), 3600)
|
|
||||||
)
|
|
||||||
|
|
||||||
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
|
|
||||||
|
|
||||||
assert {:ok,
|
|
||||||
[
|
|
||||||
%{name: ["keyword1", "keyword2"], visitors: 25},
|
|
||||||
%{name: ["keyword3", "keyword4"], visitors: 15}
|
|
||||||
]} = Google.API.fetch_stats(site, query, 5)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns error when token refresh fails", %{user: user, site: site} do
|
test "returns error when token refresh fails", %{user: user, site: site} do
|
||||||
mock_http_with("google_analytics_auth#invalid_grant.json")
|
mock_http_with("google_analytics_auth#invalid_grant.json")
|
||||||
|
|
||||||
@ -378,6 +317,81 @@ defmodule Plausible.Google.APITest do
|
|||||||
|
|
||||||
assert {:error, "invalid_grant"} = Google.API.fetch_stats(site, query, 5)
|
assert {:error, "invalid_grant"} = Google.API.fetch_stats(site, query, 5)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns error when google auth not configured", %{site: site} do
|
||||||
|
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
|
||||||
|
|
||||||
|
assert {:error, :google_property_not_configured} = Google.API.fetch_stats(site, query, 5)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "fetch_stats/3 with valid auth" do
|
||||||
|
setup %{user: user, site: site} do
|
||||||
|
insert(:google_auth,
|
||||||
|
user: user,
|
||||||
|
site: site,
|
||||||
|
property: "sc-domain:dummy.test",
|
||||||
|
expires: NaiveDateTime.add(NaiveDateTime.utc_now(), 3600)
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns name and visitor count", %{site: site} do
|
||||||
|
mock_http_with("google_analytics_stats.json")
|
||||||
|
|
||||||
|
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
|
||||||
|
|
||||||
|
assert {:ok,
|
||||||
|
[
|
||||||
|
%{name: "keyword1", visitors: 25, ctr: 36.8, impressions: 50, position: 2.2},
|
||||||
|
%{name: "keyword3", visitors: 15}
|
||||||
|
]} = Google.API.fetch_stats(site, query, 5)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms page filters to search console format", %{site: site} do
|
||||||
|
expect(
|
||||||
|
Plausible.HTTPClient.Mock,
|
||||||
|
:post,
|
||||||
|
fn
|
||||||
|
"https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Adummy.test/searchAnalytics/query",
|
||||||
|
[{"Authorization", "Bearer 123"}],
|
||||||
|
%{
|
||||||
|
dimensionFilterGroups: [
|
||||||
|
%{filters: [%{expression: "https://dummy.test/page", dimension: "page"}]}
|
||||||
|
],
|
||||||
|
dimensions: ["query"],
|
||||||
|
endDate: "2022-01-05",
|
||||||
|
rowLimit: 5,
|
||||||
|
startDate: "2022-01-01"
|
||||||
|
} ->
|
||||||
|
{:ok, %Finch.Response{status: 200, body: %{"rows" => []}}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
query =
|
||||||
|
Plausible.Stats.Query.from(site, %{
|
||||||
|
"period" => "custom",
|
||||||
|
"from" => "2022-01-01",
|
||||||
|
"to" => "2022-01-05",
|
||||||
|
"filters" => "event:page==/page"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, []} = Google.API.fetch_stats(site, query, 5)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :invalid filters when using filters that cannot be used in Search Console", %{
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
query =
|
||||||
|
Plausible.Stats.Query.from(site, %{
|
||||||
|
"period" => "custom",
|
||||||
|
"from" => "2022-01-01",
|
||||||
|
"to" => "2022-01-05",
|
||||||
|
"filters" => "event:goal==Signup"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, :unsupported_filters} = Google.API.fetch_stats(site, query, 5)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "list_views/1 returns view IDs grouped by hostname" do
|
test "list_views/1 returns view IDs grouped by hostname" do
|
||||||
|
183
test/plausible/google/search_console/filters_test.exs
Normal file
183
test/plausible/google/search_console/filters_test.exs
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
defmodule Plausible.Google.SearchConsole.FiltersTest do
|
||||||
|
alias Plausible.Google.SearchConsole.Filters
|
||||||
|
use Plausible.DataCase, async: true
|
||||||
|
|
||||||
|
test "transforms simple page filter" do
|
||||||
|
filters = %{
|
||||||
|
"visit:entry_page" => {:is, "/page"}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{filters: [%{dimension: "page", expression: "https://plausible.io/page"}]}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms matches page filter" do
|
||||||
|
filters = %{
|
||||||
|
"visit:entry_page" => {:matches, "*page*"}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{
|
||||||
|
filters: [
|
||||||
|
%{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "includingRegex",
|
||||||
|
expression: "^https://plausible\\.io.*page.*$"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms member page filter" do
|
||||||
|
filters = %{
|
||||||
|
"visit:entry_page" => {:member, ["/pageA", "/pageB"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{
|
||||||
|
filters: [
|
||||||
|
%{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "includingRegex",
|
||||||
|
expression: "https://plausible.io/pageA|https://plausible.io/pageB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms matches_member page filter" do
|
||||||
|
filters = %{
|
||||||
|
"visit:entry_page" => {:matches_member, ["/pageA*", "/pageB*"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{
|
||||||
|
filters: [
|
||||||
|
%{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "includingRegex",
|
||||||
|
expression: "^https://plausible\\.io/pageA.*$|^https://plausible\\.io/pageB.*$"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms event:page exactly like visit:entry_page" do
|
||||||
|
filters = %{
|
||||||
|
"event:page" => {:matches_member, ["/pageA*", "/pageB*"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{
|
||||||
|
filters: [
|
||||||
|
%{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "includingRegex",
|
||||||
|
expression: "^https://plausible\\.io/pageA.*$|^https://plausible\\.io/pageB.*$"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms simple visit:screen filter" do
|
||||||
|
filters = %{
|
||||||
|
"visit:screen" => {:is, "Desktop"}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [%{filters: [%{dimension: "device", expression: "DESKTOP"}]}]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms member visit:screen filter" do
|
||||||
|
filters = %{
|
||||||
|
"visit:screen" => {:member, ["Mobile", "Tablet"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{
|
||||||
|
filters: [
|
||||||
|
%{dimension: "device", operator: "includingRegex", expression: "Mobile|Tablet"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms simple visit:country filter to alpha3" do
|
||||||
|
filters = %{
|
||||||
|
"visit:country" => {:is, "EE"}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [%{filters: [%{dimension: "country", expression: "EST"}]}]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transforms member visit:country filter" do
|
||||||
|
filters = %{
|
||||||
|
"visit:country" => {:member, ["EE", "PL"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{
|
||||||
|
filters: [
|
||||||
|
%{dimension: "country", operator: "includingRegex", expression: "EST|POL"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filters can be combined" do
|
||||||
|
filters = %{
|
||||||
|
"visit:entry_page" => {:matches, "*web-analytics*"},
|
||||||
|
"visit:screen" => {:is, "Desktop"},
|
||||||
|
"visit:country" => {:member, ["EE", "PL"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
|
||||||
|
assert transformed == [
|
||||||
|
%{
|
||||||
|
filters: [
|
||||||
|
%{dimension: "device", expression: "DESKTOP"},
|
||||||
|
%{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "includingRegex",
|
||||||
|
expression: "^https://plausible\\.io.*web\\-analytics.*$"
|
||||||
|
},
|
||||||
|
%{dimension: "country", operator: "includingRegex", expression: "EST|POL"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when unsupported filter is included the whole set becomes invalid" do
|
||||||
|
filters = %{
|
||||||
|
"visit:entry_page" => {:matches, "*web-analytics*"},
|
||||||
|
"visit:screen" => {:is, "Desktop"},
|
||||||
|
"visit:country" => {:member, ["EE", "PL"]},
|
||||||
|
"visit:utm_medium" => {:is, "facebook"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert :unsupported_filters = Filters.transform("sc-domain:plausible.io", filters)
|
||||||
|
end
|
||||||
|
end
|
@ -1606,9 +1606,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "gets keywords from Google", %{conn: conn, user: user, site: site} do
|
test "gets keywords from Google", %{conn: conn, site: site} do
|
||||||
insert(:google_auth, user: user, user: user, site: site, property: "sc-domain:example.com")
|
|
||||||
|
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview,
|
build(:pageview,
|
||||||
referrer_source: "DuckDuckGo",
|
referrer_source: "DuckDuckGo",
|
||||||
@ -1627,10 +1625,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
|
|||||||
conn = get(conn, "/api/stats/#{site.domain}/referrers/Google?period=day")
|
conn = get(conn, "/api/stats/#{site.domain}/referrers/Google?period=day")
|
||||||
{:ok, terms} = Plausible.Google.API.Mock.fetch_stats(nil, nil, nil)
|
{:ok, terms} = Plausible.Google.API.Mock.fetch_stats(nil, nil, nil)
|
||||||
|
|
||||||
assert json_response(conn, 200) == %{
|
assert json_response(conn, 200) == %{"search_terms" => terms}
|
||||||
"total_visitors" => 2,
|
|
||||||
"search_terms" => terms
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "works when filter expression is provided for source", %{
|
test "works when filter expression is provided for source", %{
|
||||||
|
@ -23,7 +23,7 @@ if :minio in Keyword.fetch!(ExUnit.configuration(), :include) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
if Mix.env() == :ce_test do
|
if Mix.env() == :ce_test do
|
||||||
IO.puts("Test mode: Communnity Edition")
|
IO.puts("Test mode: Community Edition")
|
||||||
ExUnit.configure(exclude: [:slow, :minio, :ee_only])
|
ExUnit.configure(exclude: [:slow, :minio, :ee_only])
|
||||||
else
|
else
|
||||||
IO.puts("Test mode: Enterprise Edition")
|
IO.puts("Test mode: Enterprise Edition")
|
||||||
|
Loading…
Reference in New Issue
Block a user