Last active
March 4, 2024 17:47
-
-
Save alexchexes/09c81155b6dec118714825bddd814fe9 to your computer and use it in GitHub Desktop.
Google SERP Analysis Extension (tampermonkey / greasemonkey)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Google SERP Analysis Extension | |
// @namespace http://tampermonkey.net/ | |
// @version 2024-03-02 | |
// @author https://gist.github.com/alexchexes | |
// @homepageURL https://gist.github.com/alexchexes/09c81155b6dec118714825bddd814fe9 | |
// @updateURL https://gist.githubusercontent.com/alexchexes/09c81155b6dec118714825bddd814fe9/raw/google_serp_analysis.user.js | |
// @downloadURL https://gist.githubusercontent.com/alexchexes/09c81155b6dec118714825bddd814fe9/raw/google_serp_analysis.user.js | |
// @description try to take over the world! | |
// @match https://www.google.com/search?* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com | |
// @grant none | |
// @require https://code.jquery.com/jquery-latest.min.js | |
// ==/UserScript== | |
/* global $ */ | |
(function() { | |
$(function() { | |
let is_mobile = true; | |
let $table; | |
// let curr_pos = 0; | |
let curr_org_pos = 0; | |
// let curr_adv_pos = 0; | |
let search_query; | |
let search_term_in_query; | |
let detected_city; | |
let geolocation; | |
let search_query_city; | |
const TITLE_SEL_MOB = 'a > div > div[role="heading"]'; | |
const TITLE_SEL_PC = 'a > h3'; | |
if (!$(TITLE_SEL_MOB).length) { | |
is_mobile = false; | |
} | |
function parseResults() { | |
let $all_headings = $(TITLE_SEL_MOB); | |
if (!$all_headings.length) { | |
is_mobile = false; | |
$all_headings = $(TITLE_SEL_PC); | |
} | |
getSearchQuery(); | |
getDetectedCity(); | |
getGeolocation(); | |
getCityFromQuery(); | |
renderTable(); | |
// на моб сниппет с таблицей или списком имеет другую разметку. | |
// Опираемся на то, что такой сниппет это всегда ТОП-1, и добавляем его в таблицу первым | |
if (is_mobile) { | |
const $mobile_top_snip_A = $('h2').next().find('h3 a'); | |
const $mobile_top_snippet = $mobile_top_snip_A | |
.parent().parent().parent().parent().parent().parent().parent().parent().parent().parent().parent(); | |
if ($mobile_top_snippet.length) { | |
console.log('$mobile_top_snippets (ALL MATCHING): ', $mobile_top_snippet); | |
const title = $mobile_top_snip_A.text(); | |
const url = $mobile_top_snip_A.attr('href'); | |
addToTable($mobile_top_snippet, title, url); | |
} | |
} | |
$all_headings.each(function() { | |
console.log('heading elem: ', $(this)); | |
const $serp_item = $(this).parent().parent().parent().parent().parent().parent().parent().parent(); | |
let title; | |
let url; | |
if (is_mobile) { | |
title = $(this).text(); | |
url = $(this).parents('a').attr('href'); | |
} else { | |
title = $(this).text(); | |
url = $(this).parent('a').attr('href'); | |
} | |
if (url === undefined || url.indexOf('/search?') === 0) { | |
return true; | |
} | |
// skip results from "People also ask" blocks | |
if($serp_item.parents('[data-initq]').length) { | |
return true; | |
} | |
addToTable($serp_item, title, url); | |
}); | |
} | |
if (is_mobile) { | |
// костыль т.к. гугл в зависимости от аккаунта/браузера подгружает только 4 результата на мобильном, и надо скроллить | |
$('html, body').animate({ | |
scrollTop: 2000, // куда скроллим | |
}, 1); // Длительность эффекта прокрутки в миллисекундах | |
setTimeout(function(){ | |
$('html, body').animate({ | |
scrollTop: 0, // куда скроллим | |
}, 1); // Длительность эффекта прокрутки в миллисекундах | |
parseResults(); | |
}, 1000); | |
} else { | |
parseResults(); | |
} | |
function getSearchQuery() { | |
let $input; | |
if (is_mobile) { | |
$input = $('textarea[enterkeyhint="search"]'); | |
} else { | |
$input = $('textarea[role="combobox"]'); | |
} | |
search_query = $input.val(); | |
} | |
function getDetectedCity() { | |
detected_city = $('#taw omnient-visibility-control > div > div > div > span:nth-child(2) > span').text().trim(); | |
} | |
function getGeolocation() { | |
geolocation = $('.known_loc').next().text().trim(); | |
if (!geolocation) { | |
geolocation = $('.unknown_loc').next().text().trim(); | |
} | |
if (geolocation === 'Unknown') { | |
geolocation = ''; | |
} | |
} | |
function addToTable($serp_item, title, url) { | |
console.log($serp_item.eq(0)); | |
curr_org_pos++; | |
const domain = getDomainFromUrl(url); | |
const tld = getTopLevelDomainFromUrl(url); | |
let snippet_features = []; | |
if ($serp_item.find('table').length) { | |
snippet_features.push('ТОП-Таблица'); | |
} | |
if ($serp_item.find('ul, ol').length) { | |
snippet_features.push('ТОП-Список'); | |
} | |
if ($serp_item.find('[style*="width:calc"]').length) { | |
snippet_features.push('Звёзды рейтинга'); | |
} | |
const $possibly_price_elems = $serp_item.find( | |
'span:contains("₽"), span:contains("£"), span:contains("$"), span:contains("€"), span:contains("RUB")' | |
); | |
$possibly_price_elems.each(function(){ | |
console.log('$possibly_price_elems', $possibly_price_elems.text(), $possibly_price_elems); | |
let $text = $(this).text().trim().replaceAll(' ', ' '); | |
if ($text.match(/^(([A-Z]{3}|\D)\s?\d+([,\. ]\d+)*|\d+([,\. ]\d+)*\s\D)$/) | |
|| $text.match(/^(от|до)\s\d+([\., ]\d+)*\s./i)) { | |
snippet_features.push('Цены'); | |
return false; | |
} | |
}); | |
if ($serp_item.find('img').length > 1) { | |
snippet_features.push('Результат с картинкой'); | |
} | |
if ($serp_item.find('[data-attrid="wa:/description"]').length) { | |
snippet_features.push('Выделенное описание'); | |
} | |
const snippet_features_str = snippet_features.join(', '); | |
let has_navigation = ''; | |
if ($serp_item.find('a[href]:not([href*="translate.google"])').length > 2) { | |
has_navigation = 'Быстрые ссылки'; | |
} | |
const row = ` | |
<tr> | |
<td>${curr_org_pos}</td> | |
<td></td> | |
<td></td> | |
<td>${tld || ''}</td> | |
<td>${(domain !== tld) ? domain : ''}</td> | |
<td>${title || ''}</td> | |
<td>${url || ''}</td> | |
<td>${search_query}</td> | |
<td>${search_term_in_query}</td> | |
<td>${search_query_city}</td> | |
<td>${detected_city}</td> | |
<td>${geolocation}</td> | |
<td>${snippet_features_str}</td> | |
<td></td> | |
<td>${has_navigation}</td> | |
<td></td> | |
<td>${is_mobile ? 'моб.' : 'ПК'}</td> | |
<td>${getMoscowDateTime()}</td> | |
<td>Google</td> | |
</tr> | |
`; | |
$table.append(row); | |
} | |
function getAdvUrl($serp_item) { | |
const json_str = $serp_item.find('.organic__greenurl').attr('data-bem'); | |
const json_parsed = json_str ? JSON.parse(json_str) : ''; | |
return json_parsed?.click?.arguments?.url || ''; | |
} | |
function getDomainFromUrl(url) { | |
try { | |
const parsedUrl = new URL(url); | |
let hostname = parsedUrl.hostname.toLowerCase(); | |
// Remove 'www.' prefix if present | |
if (hostname.startsWith("www.")) { | |
hostname = hostname.substring(4).toLowerCase(); | |
} | |
return hostname; | |
} catch (error) { | |
console.error("Invalid URL:", error); | |
return null; | |
} | |
} | |
function getTopLevelDomainFromUrl(url) { | |
return url.replace(/.+?([\w\-]+\.[\w\-]+?)[\/\?].*/gi, '$1').toLowerCase(); | |
} | |
function renderTable() { | |
const $overlay = $(`<div class="__us_overlay"> </div>`); | |
$table = $(`<table> <tbody></tbody> </table>`); | |
const $copy_btn = $(`<div class="to__clipboard">Скопировать</div>`); | |
const css = ` | |
<style> | |
.__us_overlay { | |
background: #ffffffb8; | |
border-radius: 5px; | |
border: 1px solid black; | |
color: #000; | |
font-family: arial; | |
font-size: 12px; | |
margin: 8px 0; | |
overflow-x: auto; | |
padding: 5px 10px; | |
position: relative; | |
} | |
.__us_overlay table td { | |
max-width: 12vw; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
.__us_overlay table td:empty:after { | |
color: #b3b3b3; | |
content: "—"; | |
} | |
.to__clipboard { | |
color: crimson; | |
cursor: pointer; | |
} | |
@media only screen and (max-width: 767px) { | |
.__us_overlay table td { | |
/* max-width: 12vw; */ | |
} | |
} | |
</style> | |
`; | |
$overlay.append($copy_btn); | |
$overlay.append($table); | |
$('body').prepend(css); | |
if(is_mobile) { | |
$('#main').prepend($overlay); | |
} else { | |
$overlay.css('margin-top', '70px'); | |
$('#main > div').prepend($overlay); | |
} | |
$copy_btn.click(function(){ | |
let $btn = $(this); | |
copyWithStyle($table.get(0)); | |
let btn_text = $btn.html(); | |
$btn.html('✔'); | |
setTimeout(function(){ | |
$btn.html(btn_text); | |
}, 1500); | |
}); | |
} | |
function selectElement(elem) { | |
if(document.body.createTextRange) { | |
let range = document.body.createTextRange(); | |
range.moveToElement(elem); | |
range.select(); | |
} else if (window.getSelection) { | |
let selection = window.getSelection(); | |
let range = document.createRange(); | |
range.selectNodeContents(elem); | |
selection.removeAllRanges(); | |
selection.addRange(range); | |
} | |
} | |
function copyWithStyle(elem) { | |
selectElement(elem); | |
document.execCommand('copy'); | |
window.getSelection().removeAllRanges(); | |
} | |
function unifyString(str) { | |
return str.replaceAll(/[^\wа-яёa-z]/gi, '').toLowerCase(); | |
} | |
function getCityFromQuery() { | |
const cities = [ | |
'Балашиха', | |
'Барнаул', | |
'Белгород', | |
'Владимир', | |
'Волгоград', | |
'Воронеж', | |
'Гатчина', | |
'Екатеринбург', | |
'Ижевск', | |
'Иркутск', | |
'Казань', | |
'Королёв', | |
'Краснодар', | |
'Красноярск', | |
'Курск', | |
'Липецк', | |
'Люберцы', | |
'Москва', | |
'Мытищи', | |
'Нижний Новгород', | |
'Новая усмань', | |
'Новосибирск', | |
'Омск', | |
'Пермь', | |
'Подольск', | |
'Ростов-на-Дону', | |
'Рязань', | |
'Самара', | |
'Санкт-Петербург', | |
'Саратов', | |
'Севастополь', | |
'Симферополь', | |
'Сочи', | |
'Тверь', | |
'Тольятти', | |
'Тюмень', | |
'Уфа', | |
'Химки', | |
'Чебоксары', | |
'Челябинск', | |
'Ярославль', | |
// @todo Добавить больше городов | |
// @todo подумать над склонением многосложных типа "ростовА на дону" | |
]; | |
cities.sort((a, b) => a.length - b.length); | |
const unified_search_query = | |
search_query | |
.trim() | |
.toLowerCase() | |
.replace(/([а-яё]{3,})а /gi, '$1 ') // кейс "ростова на дону" | |
.replaceAll(/[^\wа-яёa-z]/gi, '') | |
.replace(/ё/gi, 'е') | |
.replace(/[ия]$/gi, ''); // тюменИ, ярославлЯ | |
console.log(unified_search_query) | |
for (let i = 0; i < cities.length; i++) { | |
const unified_city = | |
cities[i] | |
.toLowerCase() | |
.replaceAll(/[^\wа-яёa-z]/gi, '') | |
.replace(/ё/gi, 'е') | |
.replace(/ь$/gi, '') // тюменЬ | |
.replace(/[ия]$/gi, ''); // т.к. выше убираем для тюменИ, ярославлЯ - чтобы не поломать химкИ, тольяттИ | |
if (unified_search_query.includes(unified_city)) { | |
search_query_city = cities[i]; | |
break; | |
} | |
} | |
search_query_city = search_query_city || ''; | |
search_term_in_query = search_query_city ? search_query.replace(search_query_city, '').trim() : ''; | |
} | |
/** | |
* Gets the current date and time in Moscow time zone formatted as dd.mm.yyyy hh:mm. | |
* This function is robust and designed for production use, ensuring that it always | |
* returns the date and time in the specified format for the Moscow time zone, | |
* regardless of the user's local settings. | |
* | |
* @returns {string} The current date and time in Moscow in the format "dd.mm.yyyy hh:mm". | |
*/ | |
function getMoscowDateTime() { | |
try { | |
// Initialize the current date and time | |
const now = new Date(); | |
// Define formatting options for Moscow time zone | |
const options = { | |
year: 'numeric', | |
month: '2-digit', | |
day: '2-digit', | |
hour: '2-digit', | |
minute: '2-digit', | |
timeZone: 'Europe/Moscow', | |
hour12: false | |
}; | |
// Initialize the Intl.DateTimeFormat with Russian locale and the defined options | |
const formatter = new Intl.DateTimeFormat('ru-RU', options); | |
// Format the current date and time according to Moscow time zone | |
let moscowTime = formatter.format(now); | |
// Adjust the format to dd.mm.yyyy hh:mm | |
let formattedMoscowTime = moscowTime.replace(/(\d{2})\.(\d{2})\.(\d{4}), (\d{2}):(\d{2})/, '$1.$2.$3 $4:$5'); | |
return formattedMoscowTime; | |
} catch (error) { | |
// Log the error and potentially notify a monitoring service | |
console.error('Failed to get Moscow time:', error); | |
// Depending on the use case, you might want to rethrow the error, return null, or provide a default response | |
throw error; // or return a default value like 'Error fetching time' | |
} | |
} | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment