Advertisement
Guest User

Untitled

a guest
May 16th, 2019
104
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 20.23 KB | None | 0 0
  1. // Copyright 2015, Google Inc. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14.  
  15. /**
  16. * @name Negative Keyword Conflicts
  17. *
  18. * @overview The Negative Keyword Conflicts script generates a spreadsheet
  19. * and email alert if a Google Ads account has positive keywords which are
  20. * blocked by negative keywords. See
  21. * https://developers.google.com/google-ads/scripts/docs/solutions/negative-keyword-conflicts
  22. * for more details.
  23. *
  24. * @author Google Ads Scripts Team [adwords-scripts@googlegroups.com]
  25. *
  26. * @version 1.3.3
  27. *
  28. * @changelog
  29. * - version 1.3.3
  30. * - Added column for negative keyword list name.
  31. * - version 1.3.2
  32. * - Added validation for external spreadsheet setup.
  33. * - version 1.3.1
  34. * - Fix bug where campaigns with multiple shared negative keyword lists were
  35. * not handled correctly.
  36. * - version 1.3.0
  37. * - Fix bug where in certain cases phrase match negatives were incorrectly
  38. * reported as blocking positive keywords.
  39. * - version 1.2.1
  40. * - Improvements to time zone handling.
  41. * - version 1.2
  42. * - Improved compatibility with Large Manager Hierarchy template.
  43. * - Add option for reusing the spreadsheet or making a copy.
  44. * - version 1.1
  45. * - Bug fixes.
  46. * - version 1.0
  47. * - Released initial version.
  48. */
  49.  
  50. var CONFIG = {
  51. // URL of the spreadsheet template.
  52. // This should be a copy of https://goo.gl/M4HjaH.
  53. SPREADSHEET_URL: 'https://docs.google.com/spreadsheets/d/10TfpdqM2-q5q4waI9kmEgi5WpnuTuhjZU3PY3Yx4Sfs/edit#gid=0',
  54.  
  55. // Whether to output results to a copy of the above spreadsheet (true) or to
  56. // the spreadsheet directly, overwriting previous results (false).
  57. COPY_SPREADSHEET: false,
  58.  
  59. // Array of addresses to be alerted via email if conflicts are found.
  60. RECIPIENT_EMAILS: [
  61. 'maksskam121@gmail.com'
  62. ],
  63.  
  64. // Label on the campaigns to be processed.
  65. // Leave blank to include all campaigns.
  66. CAMPAIGN_LABEL: '',
  67.  
  68. // Limits on the number of keywords in an account the script can process.
  69. MAX_POSITIVES: 250000,
  70. MAX_NEGATIVES: 50000
  71. };
  72.  
  73. /**
  74. * Configuration to be used for running reports.
  75. */
  76. var REPORTING_OPTIONS = {
  77. // Comment out the following line to default to the latest reporting version.
  78. apiVersion: 'v201809'
  79. };
  80.  
  81. function main() {
  82. var spreadsheet = validateAndGetSpreadsheet(CONFIG.SPREADSHEET_URL);
  83. validateEmailAddresses();
  84.  
  85. var conflicts = findAllConflicts();
  86.  
  87. if (CONFIG.COPY_SPREADSHEET) {
  88. spreadsheet = spreadsheet.copy('Negative Keyword Conflicts');
  89. }
  90. initializeSpreadsheet(spreadsheet);
  91.  
  92. var hasConflicts = outputConflicts(spreadsheet,
  93. AdsApp.currentAccount().getCustomerId(), conflicts);
  94.  
  95. if (hasConflicts && CONFIG.RECIPIENT_EMAILS) {
  96. sendEmail(spreadsheet);
  97. }
  98. }
  99.  
  100. /**
  101. * Finds all negative keyword conflicts in an account.
  102. *
  103. * @return {Array.<Object>} An array of conflicts.
  104. */
  105. function findAllConflicts() {
  106. var campaignIds;
  107. if (CONFIG.CAMPAIGN_LABEL) {
  108. campaignIds = getCampaignIdsWithLabel(CONFIG.CAMPAIGN_LABEL);
  109. } else {
  110. campaignIds = getAllCampaignIds();
  111. }
  112.  
  113. var campaignCondition = '';
  114. if (campaignIds.length > 0) {
  115. campaignCondition = 'AND CampaignId IN [' + campaignIds.join(',') + ']';
  116. }
  117.  
  118. Logger.log('Downloading keywords performance report');
  119. var query =
  120. 'SELECT CampaignId, CampaignName, AdGroupId, AdGroupName, ' +
  121. ' Criteria, KeywordMatchType, IsNegative ' +
  122. 'FROM KEYWORDS_PERFORMANCE_REPORT ' +
  123. 'WHERE CampaignStatus = "ENABLED" AND AdGroupStatus = "ENABLED" AND ' +
  124. ' Status = "ENABLED" AND IsNegative IN [true, false] ' +
  125. ' ' + campaignCondition + ' ' +
  126. 'DURING YESTERDAY';
  127. var report = AdsApp.report(query, REPORTING_OPTIONS);
  128.  
  129. Logger.log('Building cache and populating with keywords');
  130. var cache = {};
  131. var numPositives = 0;
  132. var numNegatives = 0;
  133.  
  134. var rows = report.rows();
  135. while (rows.hasNext()) {
  136. var row = rows.next();
  137.  
  138. var campaignId = row['CampaignId'];
  139. var campaignName = row['CampaignName'];
  140. var adGroupId = row['AdGroupId'];
  141. var adGroupName = row['AdGroupName'];
  142. var keywordText = row['Criteria'];
  143. var keywordMatchType = row['KeywordMatchType'];
  144. var isNegative = row['IsNegative'];
  145.  
  146. if (!cache[campaignId]) {
  147. cache[campaignId] = {
  148. campaignName: campaignName,
  149. adGroups: {},
  150. negatives: [],
  151. negativesFromLists: [],
  152. };
  153. }
  154.  
  155. if (!cache[campaignId].adGroups[adGroupId]) {
  156. cache[campaignId].adGroups[adGroupId] = {
  157. adGroupName: adGroupName,
  158. positives: [],
  159. negatives: [],
  160. };
  161. }
  162.  
  163. if (isNegative == 'true') {
  164. cache[campaignId].adGroups[adGroupId].negatives
  165. .push(normalizeKeyword(keywordText, keywordMatchType));
  166. numNegatives++;
  167. } else {
  168. cache[campaignId].adGroups[adGroupId].positives
  169. .push(normalizeKeyword(keywordText, keywordMatchType));
  170. numPositives++;
  171. }
  172.  
  173. if (numPositives > CONFIG.MAX_POSITIVES ||
  174. numNegatives > CONFIG.MAX_NEGATIVES) {
  175. throw 'Trying to process too many keywords. Please restrict the ' +
  176. 'script to a smaller subset of campaigns.';
  177. }
  178. }
  179.  
  180. Logger.log('Downloading campaign negatives report');
  181. var query =
  182. 'SELECT CampaignId, Criteria, KeywordMatchType ' +
  183. 'FROM CAMPAIGN_NEGATIVE_KEYWORDS_PERFORMANCE_REPORT ' +
  184. 'WHERE CampaignStatus = "ENABLED" ' +
  185. ' ' + campaignCondition;
  186. var report = AdsApp.report(query, REPORTING_OPTIONS);
  187.  
  188. var rows = report.rows();
  189. while (rows.hasNext()) {
  190. var row = rows.next();
  191.  
  192. var campaignId = row['CampaignId'];
  193. var keywordText = row['Criteria'];
  194. var keywordMatchType = row['KeywordMatchType'];
  195.  
  196. if (cache[campaignId]) {
  197. cache[campaignId].negatives
  198. .push(normalizeKeyword(keywordText, keywordMatchType));
  199. }
  200. }
  201.  
  202. Logger.log('Populating cache with negative keyword lists');
  203. var negativeKeywordLists =
  204. AdsApp.negativeKeywordLists().withCondition('Status = ACTIVE').get();
  205.  
  206. while (negativeKeywordLists.hasNext()) {
  207. var negativeKeywordList = negativeKeywordLists.next();
  208.  
  209. var negativeList = {name: negativeKeywordList.getName(), negatives: []};
  210. var negativeKeywords = negativeKeywordList.negativeKeywords().get();
  211.  
  212. while (negativeKeywords.hasNext()) {
  213. var negative = negativeKeywords.next();
  214. negativeList.negatives.push(
  215. normalizeKeyword(negative.getText(), negative.getMatchType()));
  216. }
  217.  
  218. var campaigns = negativeKeywordList.campaigns()
  219. .withCondition('Status = ENABLED').get();
  220.  
  221. while (campaigns.hasNext()) {
  222. var campaign = campaigns.next();
  223. var campaignId = campaign.getId();
  224.  
  225. if (cache[campaignId]) {
  226. cache[campaignId].negativesFromLists =
  227. cache[campaignId].negativesFromLists.concat(negativeList);
  228. }
  229. }
  230. }
  231.  
  232. Logger.log('Finding negative conflicts');
  233. var conflicts = [];
  234.  
  235. // Adds context about the conflict.
  236. var enrichConflict = function(
  237. conflict, campaignId, adGroupId, level, opt_listName) {
  238. conflict.campaignId = campaignId;
  239. conflict.adGroupId = adGroupId;
  240. conflict.campaignName = cache[campaignId].campaignName;
  241. conflict.adGroupName = cache[campaignId].adGroups[adGroupId].adGroupName;
  242. conflict.level = level;
  243. conflict.listName = opt_listName || '-';
  244. };
  245.  
  246. for (var campaignId in cache) {
  247. for (var adGroupId in cache[campaignId].adGroups) {
  248. var positives = cache[campaignId].adGroups[adGroupId].positives;
  249.  
  250. var negativeLevels = {
  251. 'Campaign': cache[campaignId].negatives,
  252. 'Ad Group': cache[campaignId].adGroups[adGroupId].negatives
  253. };
  254.  
  255. for (var level in negativeLevels) {
  256. var newConflicts =
  257. checkForConflicts(negativeLevels[level], positives);
  258.  
  259. for (var i = 0; i < newConflicts.length; i++) {
  260. enrichConflict(newConflicts[i], campaignId, adGroupId, level);
  261. }
  262. conflicts = conflicts.concat(newConflicts);
  263. }
  264.  
  265. var negativeLists = cache[campaignId].negativesFromLists;
  266. var level = 'Negative list';
  267. for (var k = 0; k < negativeLists.length; k++) {
  268. var negativeList = negativeLists[k];
  269. var newConflicts = checkForConflicts(negativeList.negatives, positives);
  270.  
  271. for (var j = 0; j < newConflicts.length; j++) {
  272. enrichConflict(
  273. newConflicts[j], campaignId, adGroupId, level, negativeList.name);
  274. }
  275. conflicts = conflicts.concat(newConflicts);
  276. }
  277. }
  278. }
  279.  
  280. return conflicts;
  281. }
  282.  
  283. /**
  284. * Saves conflicts to a spreadsheet if present.
  285. *
  286. * @param {Object} spreadsheet The spreadsheet object.
  287. * @param {string} customerId The account the conflicts are for.
  288. * @param {Array.<Object>} conflicts A list of conflicts.
  289. * @return {boolean} True if there were conflicts and false otherwise.
  290. */
  291. function outputConflicts(spreadsheet, customerId, conflicts) {
  292. if (conflicts.length > 0) {
  293. saveConflictsToSpreadsheet(spreadsheet, customerId, conflicts);
  294. Logger.log('Conflicts were found for ' + customerId +
  295. '. See ' + spreadsheet.getUrl());
  296. return true;
  297. } else {
  298. Logger.log('No conflicts were found for ' + customerId + '.');
  299. return false;
  300. }
  301. }
  302.  
  303. /**
  304. * Sets up the spreadsheet to receive output.
  305. *
  306. * @param {Object} spreadsheet The spreadsheet object.
  307. */
  308. function initializeSpreadsheet(spreadsheet) {
  309. // Make sure the spreadsheet is using the account's timezone.
  310. spreadsheet.setSpreadsheetTimeZone(AdsApp.currentAccount().getTimeZone());
  311.  
  312. // Clear the last run date on the spreadsheet.
  313. spreadsheet.getRangeByName('RunDate').clearContent();
  314.  
  315. // Clear all rows in the spreadsheet below the header row.
  316. var outputRange = spreadsheet.getRangeByName('Headers')
  317. .offset(1, 0, spreadsheet.getSheetByName('Conflicts')
  318. .getDataRange().getLastRow())
  319. .clearContent();
  320. }
  321.  
  322. /**
  323. * Saves conflicts for a particular account to the spreadsheet starting at the
  324. * first unused row.
  325. *
  326. * @param {Object} spreadsheet The spreadsheet object.
  327. * @param {string} customerId The account that the conflicts are for.
  328. * @param {Array.<Object>} conflicts A list of conflicts.
  329. */
  330. function saveConflictsToSpreadsheet(spreadsheet, customerId, conflicts) {
  331. // Find the first open row on the Report tab below the headers and create a
  332. // range large enough to hold all of the failures, one per row.
  333. var lastRow = spreadsheet.getSheetByName('Conflicts')
  334. .getDataRange().getLastRow();
  335. var headers = spreadsheet.getRangeByName('Headers');
  336. var outputRange = headers
  337. .offset(lastRow - headers.getRow() + 1, 0, conflicts.length);
  338.  
  339. // Build each row of output values in the order of the columns.
  340. var outputValues = [];
  341. for (var i = 0; i < conflicts.length; i++) {
  342. var conflict = conflicts[i];
  343. outputValues.push([
  344. customerId,
  345. conflict.negative,
  346. conflict.level,
  347. conflict.positives.join(', '),
  348. conflict.campaignName,
  349. conflict.adGroupName,
  350. conflict.listName
  351. ]);
  352. }
  353. outputRange.setValues(outputValues);
  354.  
  355. spreadsheet.getRangeByName('RunDate').setValue(new Date());
  356.  
  357. for (var i = 0; i < CONFIG.RECIPIENT_EMAILS.length; i++) {
  358. spreadsheet.addEditor(CONFIG.RECIPIENT_EMAILS[i]);
  359. }
  360. }
  361.  
  362. /**
  363. * Sends an email to a list of email addresses with a link to the spreadsheet.
  364. *
  365. * @param {Object} spreadsheet The spreadsheet object.
  366. */
  367. function sendEmail(spreadsheet) {
  368. MailApp.sendEmail(CONFIG.RECIPIENT_EMAILS.join(','),
  369. 'Negative Keyword Conflicts Found',
  370. 'Negative keyword conflicts were found in your ' +
  371. 'Google Ads account(s). See ' +
  372. spreadsheet.getUrl() + ' for details. You may wish ' +
  373. 'to delete the negative keywords causing the ' +
  374. 'the conflicts.');
  375. }
  376.  
  377. /**
  378. * Retrieves the campaign IDs of a campaign iterator.
  379. *
  380. * @param {Object} campaigns A CampaignIterator object.
  381. * @return {Array.<Integer>} An array of campaign IDs.
  382. */
  383. function getCampaignIds(campaigns) {
  384. var campaignIds = [];
  385. while (campaigns.hasNext()) {
  386. campaignIds.push(campaigns.next().getId());
  387. }
  388.  
  389. return campaignIds;
  390. }
  391.  
  392. /**
  393. * Retrieves all campaign IDs in an account.
  394. *
  395. * @return {Array.<Integer>} An array of campaign IDs.
  396. */
  397. function getAllCampaignIds() {
  398. return getCampaignIds(AdsApp.campaigns().get());
  399. }
  400.  
  401. /**
  402. * Retrieves the campaign IDs with a given label.
  403. *
  404. * @param {string} labelText The text of the label.
  405. * @return {Array.<Integer>} An array of campaign IDs, or null if the
  406. * label was not found.
  407. */
  408. function getCampaignIdsWithLabel(labelText) {
  409. var labels = AdsApp.labels()
  410. .withCondition('Name = "' + labelText + '"')
  411. .get();
  412.  
  413. if (!labels.hasNext()) {
  414. return null;
  415. }
  416. var label = labels.next();
  417.  
  418. return getCampaignIds(label.campaigns().get());
  419. }
  420.  
  421. /**
  422. * Compares a set of negative keywords and positive keywords to identify
  423. * conflicts where a negative keyword blocks a positive keyword.
  424. *
  425. * @param {Array.<Object>} negatives A list of objects with fields
  426. * display, raw, and matchType.
  427. * @param {Array.<Object>} positives A list of objects with fields
  428. * display, raw, and matchType.
  429. * @return {Array.<Object>} An array of conflicts, each an object with
  430. * the negative keyword display text causing the conflict and an array
  431. * of blocked positive keyword display texts.
  432. */
  433. function checkForConflicts(negatives, positives) {
  434. var conflicts = [];
  435.  
  436. for (var i = 0; i < negatives.length; i++) {
  437. var negative = negatives[i];
  438. var anyBlock = false;
  439. var blockedPositives = [];
  440.  
  441. for (var j = 0; j < positives.length; j++) {
  442. var positive = positives[j];
  443.  
  444. if (negativeBlocksPositive(negative, positive)) {
  445. anyBlock = true;
  446. blockedPositives.push(positive.display);
  447. }
  448. }
  449.  
  450. if (anyBlock) {
  451. conflicts.push({
  452. negative: negative.display,
  453. positives: blockedPositives
  454. });
  455. }
  456. }
  457.  
  458. return conflicts;
  459. }
  460.  
  461. /**
  462. * Removes leading and trailing match type punctuation from the first and
  463. * last character of a keyword's text, if any.
  464. *
  465. * @param {string} text A keyword's text to remove punctuation from.
  466. * @param {string} open The character that may be the first character.
  467. * @param {string} close The character that may be the last character.
  468. * @return {Object} The same text, trimmed of open and close if present.
  469. */
  470. function trimKeyword(text, open, close) {
  471. if (text.substring(0, 1) == open &&
  472. text.substring(text.length - 1) == close) {
  473. return text.substring(1, text.length - 1);
  474. }
  475.  
  476. return text;
  477. }
  478.  
  479. /**
  480. * Normalizes a keyword by returning a raw and display version and consistent
  481. * match type. The raw version has no leading and trailing punctuation for
  482. * phrase and exact match keywords, no consecutive whitespace, is all
  483. * lowercase, and removes broad match qualifiers. The display version has no
  484. * consecutive whitespace and is all lowercase. The match type is uppercase.
  485. *
  486. * @param {string} text A keyword's text that should be normalized.
  487. * @param {string} matchType The keyword's match type.
  488. * @return {Object} An object with fields display, raw, and matchType.
  489. */
  490. function normalizeKeyword(text, matchType) {
  491. var display;
  492. var raw = text;
  493. matchType = matchType.toUpperCase();
  494.  
  495. // Replace leading and trailing "" for phrase match keywords and [] for
  496. // exact match keywords, if it is there.
  497. if (matchType == 'PHRASE') {
  498. raw = trimKeyword(raw, '"', '"');
  499. } else if (matchType == 'EXACT') {
  500. raw = trimKeyword(raw, '[', ']');
  501. }
  502.  
  503. // Collapse any runs of whitespace into single spaces.
  504. raw = raw.replace(new RegExp('\\s+', 'g'), ' ');
  505.  
  506. // Keywords are not case sensitive.
  507. raw = raw.toLowerCase();
  508.  
  509. // Set display version.
  510. display = raw;
  511. if (matchType == 'PHRASE') {
  512. display = '"' + display + '"';
  513. } else if (matchType == 'EXACT') {
  514. display = '[' + display + ']';
  515. }
  516.  
  517. // Remove broad match modifier '+' sign.
  518. raw = raw.replace(new RegExp('\\s\\+', 'g'), ' ');
  519.  
  520. return {display: display, raw: raw, matchType: matchType};
  521. }
  522.  
  523. /**
  524. * Tests whether all of the tokens in one keyword's raw text appear in
  525. * the tokens of a second keyword's text.
  526. *
  527. * @param {string} keywordText1 the raw keyword text whose tokens may
  528. * appear in the other keyword text.
  529. * @param {string} keywordText2 the raw keyword text which may contain
  530. * the tokens of the other keyword.
  531. * @return {boolean} Whether all tokens in keywordText1 appear among
  532. * the tokens of keywordText2.
  533. */
  534. function hasAllTokens(keywordText1, keywordText2) {
  535. var keywordTokens1 = keywordText1.split(' ');
  536. var keywordTokens2 = keywordText2.split(' ');
  537.  
  538. for (var i = 0; i < keywordTokens1.length; i++) {
  539. if (keywordTokens2.indexOf(keywordTokens1[i]) == -1) {
  540. return false;
  541. }
  542. }
  543.  
  544. return true;
  545. }
  546.  
  547. /**
  548. * Tests whether all of the tokens in one keyword's raw text appear in
  549. * order in the tokens of a second keyword's text.
  550. *
  551. * @param {string} keywordText1 the raw keyword text whose tokens may
  552. * appear in the other keyword text.
  553. * @param {string} keywordText2 the raw keyword text which may contain
  554. * the tokens of the other keyword in order.
  555. * @return {boolean} Whether all tokens in keywordText1 appear in order
  556. * among the tokens of keywordText2.
  557. */
  558. function isSubsequence(keywordText1, keywordText2) {
  559. return (' ' + keywordText2 + ' ').indexOf(' ' + keywordText1 + ' ') >= 0;
  560. }
  561.  
  562. /**
  563. * Tests whether a negative keyword blocks a positive keyword, taking into
  564. * account their match types.
  565. *
  566. * @param {Object} negative An object with fields raw and matchType.
  567. * @param {Object} positive An object with fields raw and matchType.
  568. * @return {boolean} Whether the negative keyword blocks the positive keyword.
  569. */
  570. function negativeBlocksPositive(negative, positive) {
  571. var isNegativeStricter;
  572.  
  573. switch (positive.matchType) {
  574. case 'BROAD':
  575. isNegativeStricter = negative.matchType != 'BROAD';
  576. break;
  577.  
  578. case 'PHRASE':
  579. isNegativeStricter = negative.matchType == 'EXACT';
  580. break;
  581.  
  582. case 'EXACT':
  583. isNegativeStricter = false;
  584. break;
  585. }
  586.  
  587. if (isNegativeStricter) {
  588. return false;
  589. }
  590.  
  591. switch (negative.matchType) {
  592. case 'BROAD':
  593. return hasAllTokens(negative.raw, positive.raw);
  594. break;
  595.  
  596. case 'PHRASE':
  597. return isSubsequence(negative.raw, positive.raw);
  598. break;
  599.  
  600. case 'EXACT':
  601. return positive.raw === negative.raw;
  602. break;
  603. }
  604. }
  605.  
  606. /**
  607. * Validates the provided spreadsheet URL to make sure that it's set up
  608. * properly. Throws a descriptive error message if validation fails.
  609. *
  610. * @param {string} spreadsheeturl The URL of the spreadsheet to open.
  611. * @return {Spreadsheet} The spreadsheet object itself, fetched from the URL.
  612. * @throws {Error} If the spreadsheet URL hasn't been set
  613. */
  614. function validateAndGetSpreadsheet(spreadsheeturl) {
  615. if (spreadsheeturl == 'YOUR_SPREADSHEET_URL') {
  616. throw new Error('Please specify a valid Spreadsheet URL. You can find' +
  617. ' a link to a template in the associated guide for this script.');
  618. }
  619. return SpreadsheetApp.openByUrl(spreadsheeturl);
  620. }
  621.  
  622. /**
  623. * Validates the provided email address to make sure it's not the default.
  624. * Throws a descriptive error message if validation fails.
  625. *
  626. * @throws {Error} If the list of email addresses is still the default
  627. */
  628. function validateEmailAddresses() {
  629. if (CONFIG.RECIPIENT_EMAILS &&
  630. CONFIG.RECIPIENT_EMAILS[0] == 'YOUR_EMAIL_HERE') {
  631. throw new Error('Please either specify a valid email address or clear' +
  632. ' the RECIPIENT_EMAILS field.');
  633. }
  634. }
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement