Xung đột từ khóa phủ định - Tài khoản đơn lẻ

Biểu tượng cảnh báo

Từ khoá phủ định được dùng để ngăn quảng cáo xuất hiện cho những cụm từ tìm kiếm không liên quan. Tuy nhiên, từ khoá phủ định có thể vô tình chặn không cho từ khoá thông thường khớp với các cụm từ tìm kiếm có liên quan, khiến chiến dịch của bạn kém hiệu quả hơn. Lý do phổ biến nhất khiến từ khoá phủ định xung đột với từ khoá thông thường là từ khoá phủ định được tạo với kiểu khớp rộng hơn dự định. Xung đột cũng có thể phát sinh nếu nhiều người cập nhật từ khoá trong một tài khoản cùng một lúc.

Xung đột từ khoá phủ định kiểm tra xem các từ khoá phủ định trong một tài khoản có chặn mọi từ khoá thông thường hay không. Tập lệnh sẽ tìm và lưu tất cả các xung đột đó vào bảng tính và phân phối cảnh báo qua email. Sau đó, người nhận có thể thực hiện hành động thích hợp, chẳng hạn như xoá các từ khoá phủ định gây ra xung đột.

Lập lịch

Lập lịch để tập lệnh chạy thường xuyên như bạn cập nhật từ khoá. Ví dụ: nếu cập nhật từ khoá thường xuyên, bạn có thể lập lịch để tập lệnh chạy Hằng giờ. Nếu cập nhật từ khoá ít thường xuyên hơn, bạn có thể lên lịch để chạy Hằng ngày hoặc thậm chí ít thường xuyên hơn. Việc chạy tập lệnh thường xuyên hơn việc cập nhật từ khoá sẽ không có giá trị gì, vì cách duy nhất để xung đột mới xảy ra là khi từ khoá đã thay đổi.

Cách hoạt động

Tập lệnh sử dụng báo cáo để tìm nạp tất cả từ khoá phủ định và từ khoá thông thường trên tài khoản, đồng thời kiểm tra xem có từ khoá thông thường nào bị chặn hay không. Tất cả từ khoá phủ định đều được xem xét, bao gồm từ khoá phủ định ở cấp chiến dịch, từ khoá phủ định ở cấp nhóm quảng cáo và danh sách từ khoá phủ định đi kèm với một chiến dịch.

Việc từ khoá phủ định có chặn một từ khoá thông thường hay không phụ thuộc vào kiểu khớp của từ khoá đó. Nhìn chung, một từ khoá phủ định dùng kiểu khớp nghiêm ngặt hơn từ khoá thông thường sẽ không thể chặn từ khoá đó vì từ khoá thông thường sẽ khớp với phạm vi cụm từ tìm kiếm rộng hơn.

Ví dụ: từ khoá phủ định khớp chính xác [silk scarves] không chặn một từ khoá khớp mở rộng thông thường silk scarves vì từ khoá thứ hai sẽ khớp với các cụm từ tìm kiếm như scarves silk hoặc women's silk scarves mà từ khoá phủ định không lọc ra được.

Sau đây là các quy tắc:

  • Từ khoá phủ định khớp chính xác như [silk scarves] sẽ chỉ chặn một từ khoá khớp chính xác thông thường giống hệt nhau.
  • Một từ khoá phủ định khớp cụm từ như "silk scarves" sẽ chặn bất kỳ từ khoá khớp cụm từ hoặc từ khoá thông thường nào khớp chính xác có chứa cụm từ silk scarves.
  • Từ khoá phủ định khớp mở rộng như silk scarves sẽ chặn mọi từ khoá thông thường (bất kể kiểu khớp) có chứa các từ silkscarves theo thứ tự bất kỳ.

Lỗi

Nếu tìm thấy bất kỳ xung đột nào, tập lệnh sẽ xuất thông tin chi tiết sang bảng tính và gửi cảnh báo qua email đến danh sách người nhận. Tập lệnh không tạo bảng tính hoặc gửi email nếu không có xung đột.

Số lần bị tạm ngừng

Tập lệnh có thể hết thời gian chờ cho những tài khoản có hơn 250.000 từ khoá và 50.000 từ khoá phủ định. Đối với các tài khoản này, bạn có thể tạo nhiều bản sao tập lệnh và đặt CAMPAIGN_LABEL để mỗi bản sao hoạt động trên một nhóm nhỏ chiến dịch khác nhau.

Thiết lập

  • Hãy thiết lập một tập lệnh có mã nguồn bên dưới. Hãy sử dụng bản sao của bảng tính mẫu này.
  • Đừng quên cập nhật SPREADSHEET_URLRECIPIENT_EMAILS trong tập lệnh.
  • Nếu muốn, hãy cung cấp CAMPAIGN_LABEL để tập lệnh chỉ hoạt động trên một nhóm nhỏ các chiến dịch của bạn.
  • Lên lịch cho tập lệnh.

Mã nguồn

// Copyright 2015, Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @name Negative Keyword Conflicts
 *
 * @overview The Negative Keyword Conflicts script generates a spreadsheet
 *     and email alert if a Google Ads account has positive keywords which are
 *     blocked by negative keywords. See
 *     https://developers.google.com/google-ads/scripts/docs/solutions/negative-keyword-conflicts
 *     for more details.
 *
 * @author Google Ads Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 2.1
 *
 * @changelog
 * - version 2.1
 *   - Fix bug where negative keywords were not categorized correctly.
 * - version 2.0
 *   - Updated to use new Google Ads scripts features.
 * - version 1.3.3
 *   - Added column for negative keyword list name.
 * - version 1.3.2
 *   - Added validation for external spreadsheet setup.
 * - version 1.3.1
 *   - Fix bug where campaigns with multiple shared negative keyword lists were
 *     not handled correctly.
 * - version 1.3.0
 *   - Fix bug where in certain cases phrase match negatives were incorrectly
 *     reported as blocking positive keywords.
 * - version 1.2.1
 *   - Improvements to time zone handling.
 * - version 1.2
 *   - Improved compatibility with Large Manager Hierarchy template.
 *   - Add option for reusing the spreadsheet or making a copy.
 * - version 1.1
 *   - Bug fixes.
 * - version 1.0
 *   - Released initial version.
 */

const CONFIG = {
  // URL of the spreadsheet template.
  // This should be a copy of https://goo.gl/M4HjaH.
  SPREADSHEET_URL: 'YOUR_SPREADSHEET_URL',

  // Whether to output results to a copy of the above spreadsheet (true) or to
  // the spreadsheet directly, overwriting previous results (false).
  COPY_SPREADSHEET: false,

  // Array of addresses to be alerted via email if conflicts are found.
  RECIPIENT_EMAILS: [
    'YOUR_EMAIL_HERE'
  ],

  // Label on the campaigns to be processed.
  // Leave blank to include all campaigns.
  CAMPAIGN_LABEL: '',

  // Limits on the number of keywords in an account the script can process.
  MAX_POSITIVES: 250000,
  MAX_NEGATIVES: 50000
};

/**
 * Configuration to be used for running reports.
 */
const REPORTING_OPTIONS = {
  // Comment out the following line to default to the latest reporting version.
  apiVersion: 'v11'
};

function main() {
  let spreadsheet = validateAndGetSpreadsheet(CONFIG.SPREADSHEET_URL);
  validateEmailAddresses();

  const conflicts = findAllConflicts();

  if (CONFIG.COPY_SPREADSHEET) {
    spreadsheet = spreadsheet.copy('Negative Keyword Conflicts');
  }
  initializeSpreadsheet(spreadsheet);

  const hasConflicts = outputConflicts(spreadsheet,
    AdsApp.currentAccount().getCustomerId(), conflicts);

  if (hasConflicts && CONFIG.RECIPIENT_EMAILS) {
    sendEmail(spreadsheet);
  }
}

/**
 * Finds all negative keyword conflicts in an account.
 *
 * @return {Array.<Object>} An array of conflicts.
 */
function findAllConflicts() {
  let campaignIds;
  if (CONFIG.CAMPAIGN_LABEL) {
    campaignIds = getCampaignIdsWithLabel(CONFIG.CAMPAIGN_LABEL);
  } else {
    campaignIds = getAllCampaignIds();
  }

  let campaignCondition = '';
  if (campaignIds.length > 0) {
    campaignCondition = `AND campaign.id IN (${campaignIds.join(',')})`;
  }

  console.log('Downloading keywords performance report');
  let query =
      `SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ` +
      `ad_group_criterion.keyword.text, ` +
      `ad_group_criterion.keyword.match_type, ` +
      `ad_group_criterion.negative FROM keyword_view ` +
      `WHERE campaign.status = "ENABLED" AND ad_group.status = "ENABLED" `+
      `AND ad_group_criterion.status = "ENABLED" ` +
      `${campaignCondition} AND segments.date DURING YESTERDAY`;
  let report = AdsApp.report(query, REPORTING_OPTIONS);

  console.log('Building cache and populating with keywords');
  let cache = {};
  let numPositives = 0;
  let numNegatives = 0;

  let rows = report.rows();
  for (const row of rows) {

    const campaignId = row['campaign.id'];
    const campaignName = row['campaign.name'];
    const adGroupId = row['ad_group.id'];
    const adGroupName = row['ad_group.name'];
    const keywordText = row['ad_group_criterion.keyword.text'];
    const keywordMatchType = row['ad_group_criterion.keyword.match_type'];
    const isNegative = row['ad_group_criterion.negative'];

    if (!cache[campaignId]) {
      cache[campaignId] = {
        campaignName: campaignName,
        adGroups: {},
        negatives: [],
        negativesFromLists: [],
      };
    }

    if (!cache[campaignId].adGroups[adGroupId]) {
      cache[campaignId].adGroups[adGroupId] = {
        adGroupName: adGroupName,
        positives: [],
        negatives: [],
      };
    }

    if (isNegative) {
      cache[campaignId].adGroups[adGroupId].negatives
        .push(normalizeKeyword(keywordText, keywordMatchType));
      numNegatives++;
    } else {
      cache[campaignId].adGroups[adGroupId].positives
        .push(normalizeKeyword(keywordText, keywordMatchType));
      numPositives++;
    }

    if (numPositives > CONFIG.MAX_POSITIVES ||
        numNegatives > CONFIG.MAX_NEGATIVES) {
      throw 'Trying to process too many keywords. Please restrict the ' +
            'script to a smaller subset of campaigns.';
    }
  }

  console.log('Downloading campaign negatives report');
  query =
      `SELECT campaign.id, campaign_criterion.keyword.text, ` +
      `campaign_criterion.keyword.match_type FROM campaign_criterion ` +
      `WHERE campaign_criterion.negative = true AND ` +
      `campaign_criterion.type = "KEYWORD" AND ` +
      `campaign.status = "ENABLED" ${campaignCondition}`;
  report = AdsApp.report(query, REPORTING_OPTIONS);

  rows = report.rows();
  for (const row of rows) {

    const campaignId = row['campaign.id'];
    const keywordText = row['campaign_criterion.keyword.text'];
    const keywordMatchType = row['campaign_criterion.keyword.match_type'];

    if (cache[campaignId]) {
      cache[campaignId].negatives
        .push(normalizeKeyword(keywordText, keywordMatchType));
    }
  }

  console.log('Populating cache with negative keyword lists');
  const negativeKeywordLists =
    AdsApp.negativeKeywordLists().withCondition('Status = ACTIVE').get();

  for (const negativeKeywordList of negativeKeywordLists) {
    const negativeList = {name: negativeKeywordList.getName(), negatives: []};
    const negativeKeywords = negativeKeywordList.negativeKeywords().get();

    for (const negative of negativeKeywords) {
      negativeList.negatives.push(
          normalizeKeyword(negative.getText(), negative.getMatchType()));
    }

    const campaigns = negativeKeywordList.campaigns()
        .withCondition('Status = ENABLED').get();

    for (const campaign of campaigns) {
      const campaignId = campaign.getId();

      if (cache[campaignId]) {
        cache[campaignId].negativesFromLists =
            cache[campaignId].negativesFromLists.concat(negativeList);
      }
    }
  }

  console.log('Finding negative conflicts');
  let conflicts = [];

  // Adds context about the conflict.
  const enrichConflict = function(
      conflict, campaignId, adGroupId, level, opt_listName) {
    conflict.campaignId = campaignId;
    conflict.adGroupId = adGroupId;
    conflict.campaignName = cache[campaignId].campaignName;
    conflict.adGroupName = cache[campaignId].adGroups[adGroupId].adGroupName;
    conflict.level = level;
    conflict.listName = opt_listName || '-';
  };

  for (const campaignId in cache) {
    for (const adGroupId in cache[campaignId].adGroups) {
      const positives = cache[campaignId].adGroups[adGroupId].positives;

      const negativeLevels = {
        'Campaign': cache[campaignId].negatives,
        'Ad Group': cache[campaignId].adGroups[adGroupId].negatives
      };

      for (const level in negativeLevels) {
        const newConflicts =
          checkForConflicts(negativeLevels[level], positives);

        for (const newConflict of newConflicts) {
          enrichConflict(newConflict, campaignId, adGroupId, level);
        }
        conflicts = conflicts.concat(newConflicts);
      }

      const negativeLists = cache[campaignId].negativesFromLists;
      const level = 'Negative list';
      for (const negativeList of negativeLists) {
        const newConflicts = checkForConflicts(
            negativeList.negatives, positives);

        for (const newConflict of newConflicts) {
          enrichConflict(
              newConflict, campaignId, adGroupId, level, negativeList.name);
        }
        conflicts = conflicts.concat(newConflicts);
      }
    }
  }

  return conflicts;
}

/**
 * Saves conflicts to a spreadsheet if present.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {string} customerId The account the conflicts are for.
 * @param {Array.<Object>} conflicts A list of conflicts.
 * @return {boolean} True if there were conflicts and false otherwise.
 */
function outputConflicts(spreadsheet, customerId, conflicts) {
  if (conflicts.length > 0) {
    saveConflictsToSpreadsheet(spreadsheet, customerId, conflicts);
    console.log(`Conflicts were found for ${customerId}` +
               `. See ${spreadsheet.getUrl()}`);
    return true;
  } else {
    console.log(`No conflicts were found for ${customerId}.`);
    return false;
  }
}

/**
 * Sets up the spreadsheet to receive output.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 */
function initializeSpreadsheet(spreadsheet) {
  // Make sure the spreadsheet is using the account's timezone.
  spreadsheet.setSpreadsheetTimeZone(AdsApp.currentAccount().getTimeZone());

  // Clear the last run date on the spreadsheet.
  spreadsheet.getRangeByName('RunDate').clearContent();

  // Clear all rows in the spreadsheet below the header row.
  spreadsheet.getRangeByName('Headers')
    .offset(1, 0, spreadsheet.getSheetByName('Conflicts')
    .getDataRange().getLastRow())
    .clearContent();
}

/**
 * Saves conflicts for a particular account to the spreadsheet starting at the
 * first unused row.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {string} customerId The account that the conflicts are for.
 * @param {Array.<Object>} conflicts A list of conflicts.
 */
function saveConflictsToSpreadsheet(spreadsheet, customerId, conflicts) {
  // Find the first open row on the Report tab below the headers and create a
  // range large enough to hold all of the failures, one per row.
  const lastRow = spreadsheet.getSheetByName('Conflicts')
    .getDataRange().getLastRow();
  const headers = spreadsheet.getRangeByName('Headers');
  const outputRange = headers
    .offset(lastRow - headers.getRow() + 1, 0, conflicts.length);

  // Build each row of output values in the order of the columns.
  const outputValues = [];
  for (const conflict of conflicts) {
    outputValues.push([
      customerId,
      conflict.negative,
      conflict.level,
      conflict.positives.join(', '),
      conflict.campaignName,
      conflict.adGroupName,
      conflict.listName
    ]);
  }
  outputRange.setValues(outputValues);

  spreadsheet.getRangeByName('RunDate').setValue(new Date());

  for (const recipientEmail of CONFIG.RECIPIENT_EMAILS) {
    spreadsheet.addEditor(recipientEmail);
  }
}

/**
 * Sends an email to a list of email addresses with a link to the spreadsheet.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 */
function sendEmail(spreadsheet) {
  MailApp.sendEmail(CONFIG.RECIPIENT_EMAILS.join(','),
      'Negative Keyword Conflicts Found',
      `Negative keyword conflicts were found in your ` +
      `Google Ads account(s). See ` +
      `${spreadsheet.getUrl()} for details. You may wish ` +
      `to delete the negative keywords causing the conflicts.`);
}

/**
 * Retrieves the campaign IDs of a campaign iterator.
 *
 * @param {Object} campaigns A CampaignIterator object.
 * @return {Array.<Integer>} An array of campaign IDs.
 */
function getCampaignIds(campaigns) {
  const campaignIds = [];
  for (const campaign of campaigns) {
    campaignIds.push(campaign.getId());
  }

  return campaignIds;
}

/**
 * Retrieves all campaign IDs in an account.
 *
 * @return {Array.<Integer>} An array of campaign IDs.
 */
function getAllCampaignIds() {
  return getCampaignIds(AdsApp.campaigns().get());
}

/**
 * Retrieves the campaign IDs with a given label.
 *
 * @param {string} labelText The text of the label.
 * @return {Array.<Integer>} An array of campaign IDs, or null if the
 *     label was not found.
 */
function getCampaignIdsWithLabel(labelText) {
  const labels = AdsApp.labels()
    .withCondition('Name = "' + labelText + '"')
    .get();

  if (!labels.hasNext()) {
    return null;
  }
  const label = labels.next();

  return getCampaignIds(label.campaigns().get());
}

/**
 * Compares a set of negative keywords and positive keywords to identify
 * conflicts where a negative keyword blocks a positive keyword.
 *
 * @param {Array.<Object>} negatives A list of objects with fields
 *     display, raw, and matchType.
 * @param {Array.<Object>} positives A list of objects with fields
 *     display, raw, and matchType.
 * @return {Array.<Object>} An array of conflicts, each an object with
 *     the negative keyword display text causing the conflict and an array
 *     of blocked positive keyword display texts.
 */
function checkForConflicts(negatives, positives) {
  const conflicts = [];

  for (const negative of negatives) {
    let anyBlock = false;
    const blockedPositives = [];

    for (const positive of positives) {

      if (negativeBlocksPositive(negative, positive)) {
        anyBlock = true;
        blockedPositives.push(positive.display);
      }
    }

    if (anyBlock) {
      conflicts.push({
        negative: negative.display,
        positives: blockedPositives
      });
    }
  }

  return conflicts;
}

/**
 * Removes leading and trailing match type punctuation from the first and
 * last character of a keyword's text, if any.
 *
 * @param {string} text A keyword's text to remove punctuation from.
 * @param {string} open The character that may be the first character.
 * @param {string} close The character that may be the last character.
 * @return {Object} The same text, trimmed of open and close if present.
 */
function trimKeyword(text, open, close) {
  if (text.substring(0, 1) == open &&
      text.substring(text.length - 1) == close) {
    return text.substring(1, text.length - 1);
  }

  return text;
}

/**
 * Normalizes a keyword by returning a raw and display version and consistent
 * match type. The raw version has no leading and trailing punctuation for
 * phrase and exact match keywords, no consecutive whitespace, is all
 * lowercase, and removes broad match qualifiers. The display version has no
 * consecutive whitespace and is all lowercase. The match type is uppercase.
 *
 * @param {string} text A keyword's text that should be normalized.
 * @param {string} matchType The keyword's match type.
 * @return {Object} An object with fields display, raw, and matchType.
 */
function normalizeKeyword(text, matchType) {
  let display;
  let raw = text;
  matchType = matchType.toUpperCase();

  // Replace leading and trailing "" for phrase match keywords and [] for
  // exact match keywords, if it is there.
  if (matchType == 'PHRASE') {
    raw = trimKeyword(raw, '"', '"');
  } else if (matchType == 'EXACT') {
    raw = trimKeyword(raw, '[', ']');
  }

  // Collapse any runs of whitespace into single spaces.
  raw = raw.replace(new RegExp('\\s+', 'g'), ' ');

  // Keywords are not case sensitive.
  raw = raw.toLowerCase();

  // Set display version.
  display = raw;
  if (matchType == 'PHRASE') {
    display = '"' + display + '"';
  } else if (matchType == 'EXACT') {
    display = '[' + display + ']';
  }

  // Remove broad match modifier '+' sign.
  raw = raw.replace(new RegExp('\\s\\+', 'g'), ' ');

  return {display: display, raw: raw, matchType: matchType};
}

/**
 * Tests whether all of the tokens in one keyword's raw text appear in
 * the tokens of a second keyword's text.
 *
 * @param {string} keywordText1 the raw keyword text whose tokens may
 *     appear in the other keyword text.
 * @param {string} keywordText2 the raw keyword text which may contain
 *     the tokens of the other keyword.
 * @return {boolean} Whether all tokens in keywordText1 appear among
 *     the tokens of keywordText2.
 */
function hasAllTokens(keywordText1, keywordText2) {
  const keywordTokens1 = keywordText1.split(' ');
  const keywordTokens2 = keywordText2.split(' ');

  for (const keywordToken of keywordTokens1) {
    if (keywordTokens2.indexOf(keywordToken) == -1) {
      return false;
    }
  }

  return true;
}

/**
 * Tests whether all of the tokens in one keyword's raw text appear in
 * order in the tokens of a second keyword's text.
 *
 * @param {string} keywordText1 the raw keyword text whose tokens may
 *     appear in the other keyword text.
 * @param {string} keywordText2 the raw keyword text which may contain
 *     the tokens of the other keyword in order.
 * @return {boolean} Whether all tokens in keywordText1 appear in order
 *     among the tokens of keywordText2.
 */
function isSubsequence(keywordText1, keywordText2) {
  return (' ' + keywordText2 + ' ').indexOf(' ' + keywordText1 + ' ') >= 0;
}

/**
 * Tests whether a negative keyword blocks a positive keyword, taking into
 * account their match types.
 *
 * @param {Object} negative An object with fields raw and matchType.
 * @param {Object} positive An object with fields raw and matchType.
 * @return {boolean} Whether the negative keyword blocks the positive keyword.
 */
function negativeBlocksPositive(negative, positive) {
  let isNegativeStricter;

  switch (positive.matchType) {
    case 'BROAD':
      isNegativeStricter = negative.matchType != 'BROAD';
      break;

    case 'PHRASE':
      isNegativeStricter = negative.matchType == 'EXACT';
      break;

    case 'EXACT':
      isNegativeStricter = false;
      break;
  }

  if (isNegativeStricter) {
    return false;
  }

  switch (negative.matchType) {
    case 'BROAD':
      return hasAllTokens(negative.raw, positive.raw);
      break;

    case 'PHRASE':
      return isSubsequence(negative.raw, positive.raw);
      break;

    case 'EXACT':
      return positive.raw === negative.raw;
      break;
  }
}

/**
 * Validates the provided spreadsheet URL to make sure that it's set up
 * properly. Throws a descriptive error message if validation fails.
 *
 * @param {string} spreadsheeturl The URL of the spreadsheet to open.
 * @return {Spreadsheet} The spreadsheet object itself, fetched from the URL.
 * @throws {Error} If the spreadsheet URL hasn't been set
 */
function validateAndGetSpreadsheet(spreadsheeturl) {
  if (spreadsheeturl == 'YOUR_SPREADSHEET_URL') {
    throw new Error('Please specify a valid Spreadsheet URL. You can find' +
        ' a link to a template in the associated guide for this script.');
  }
  return SpreadsheetApp.openByUrl(spreadsheeturl);
}

/**
 * Validates the provided email address to make sure it's not the default.
 * Throws a descriptive error message if validation fails.
 *
 * @throws {Error} If the list of email addresses is still the default
 */
function validateEmailAddresses() {
  if (CONFIG.RECIPIENT_EMAILS &&
      CONFIG.RECIPIENT_EMAILS[0] == 'YOUR_EMAIL_HERE') {
    throw new Error('Please either specify a valid email address or clear' +
        ' the RECIPIENT_EMAILS field.');
  }
}