おことわり
この記事のコードには、新しい版 (GitHub)が存在します。
前提
- Unity 2018.4.5f1、2018.4.30f1
- Unity IAP 1.22.0、2.2.4
- Apple App Store、Google Play Store
- この記事では、Unity IAPの一部機能を限定的に使用し、汎用性のない部分があります。
- サーバレス、消費/非消費タイプ使用、購読タイプ未使用
- この記事のソースは、実際のストアでテストしていますが、製品版での使用実績はありません。
- ソース中のIDは実際と異なります。
公式ドキュメント
- マニュアル
- スクリプトリファレンス
- 2019/08/02時点で、バージョンによって記述があったりなかったりします。
- 記述あり 5.6、2017.1、2017.2、2017.3、2018.1、2018.2
- 記述なし 2017.4、2018.3、2018.4、2019.1
ネームスペース
-
UnityEngine.Purchasing
- 必須のネームスペースです。
-
UnityEngine.Purchasing.Security
- レシートの検証で必要なネームスペースです。
- スクリプトリファレンスに記述が見つかりません。
-
UnityEngine.Purchasing.Extension
- この記事では扱いません。
初期化
初期化の開始
-
UnityPurchasing.Initialize ()
を呼ぶことで、初期化を開始します。
UnityPurchasing.Initialize (Purchaser instance, ConfigurationBuilder builder);
- 初期化の要求はブロックされず、後に、結果に応じたコールバックがあります。
イベントハンドラ
- コールバックを受け取るために、
IStoreListener
を継承したクラスのインスタンスが必要です。- 必ずしも
MonoBehaviour
を継承する必要はありません。 - インターフェイス
IStoreListener
では、OnInitialized ()
、OnInitializeFailed ()
、OnPurchaseFailed ()
、ProcessPurchase ()
の4つのイベントハンドラが必要になります。
- 必ずしも
準備
-
Initialize ()
を呼ぶためには、ConfigurationBuilder builder
のインスタンスを得る必要があります。 -
ConfigurationBuilder.Instance ()
を呼ぶためには、IPurchasingModule
を継承したクラスのインスタンスが必要なようですが、この辺りを記載したドキュメントが見つかりません。 - 付属のDemoでは、
StandardPurchasingModule
がそのクラスにあたるようで、そのインスタンスを得て使われています。 - 得られたインスタンス
module
にストアの設定を行い、さらにbuilder
インスタンスを得ます。 - 得られたインスタンス
builder
に製品を登録し、検証器を生成して、初期化を開始します。 - ここでは、クラスのコンストラクタで、準備から初期化の開始までを行っています。
- コンストラクタが
private
なのは、シングルトンで使うためです。
- コンストラクタが
Purchaser.cs
/// <summary>コンストラクタ</summary>
private Purchaser (IEnumerable<ProductDefinition> products) {
Debug.Log ("Purchaser.Construct");
var module = StandardPurchasingModule.Instance ();
module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
isGooglePlayStoreSelected = Application.platform == RuntimePlatform.Android && module.appStore == AppStore.GooglePlay;
isAppleAppStoreSelected = Application.platform == RuntimePlatform.IPhonePlayer; // && module.appStore == AppStore.AppleAppStore;
validator = new CrossPlatformValidator (GooglePlayTangle.Data (), AppleTangle.Data (), Application.identifier);
var builder = ConfigurationBuilder.Instance (module);
builder.AddProducts (products);
Inventory = new Inventory { };
UnityPurchasing.Initialize (this, builder);
}
製品定義
- 先のコンストラクタが受け取って
builder
に登録した製品定義は、製品のIDとタイプのセットです。
Sample.cs
var products = new [] {
new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item1", ProductType.Consumable),
new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item2", ProductType.NonConsumable),
new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item3", ProductType.NonConsumable),
};
- これらはストア・ダッシュボードでの設定と正しく呼応している必要があります。
- Apple App Storeでは、IDと製品タイプの双方が設定されます。
- Google Play Storeでは、IDが設定されますが、消費の有無についての設定はありません。
- 消費タイプでは、アプリを消費したことを申告するだけです。
- この記事では、消費タイプ
Consumable
と非消費タイプNonConsumable
だけを扱い、購読タイプは扱いません。
初期化の完了
- 初期化に成功したら、得られた
IStoreController
とIExtensionProvider
を保存します。
Purchaser.cs
/// <summary>初期化完了</summary>
public void OnInitialized (IStoreController controller, IExtensionProvider extensions) {
Debug.Log ($"Purchaser.Initialized {controller}, {extensions}");
appleExtensions = extensions.GetExtension<IAppleExtensions> ();
appleExtensions.RegisterPurchaseDeferredListener (OnDeferred);
googlePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions> ();
this.controller = controller;
this.extensions = extensions;
foreach (var product in controller.products.all) {
if (!Inventory.ContainsKey (product)) {
Inventory [product] = possession (product);
}
}
}
/// <summary>初期化失敗</summary>
public void OnInitializeFailed (InitializationFailureReason error) {
Debug.LogError ($"Purchaser.InitializeFailed {error}");
Unavailable = true;
}
- iOSの'Ask to buy'関連、
OnDeferred
はテストできていません。 -
Inventory
については、後述します。
製品目録
- 初期化に成功すると、
controller.products.all
で、製品目録を得ることができます。
Sample.cs
foreach (var product in Purchaser.Products.all) {
Debug.Log (Purchaser.GetProductProperties (product));
}
Purchaser.cs
/// <summary>製品目録 初期化時の製品IDに対してストアから得た情報</summary>
public static ProductCollection Products => Valid ? instance.controller.products : null;
Purchaser.cs
/// <summary>製品諸元</summary>
public static string GetProperties (this Product product) {
return string.Join ("\n", new [] {
$"id={product.definition.id} ({product.definition.storeSpecificId})",
$"type={product.definition.type}",
$"enabled={product.definition.enabled}",
$"available={product.availableToPurchase}",
$"localizedTitle={product.metadata.localizedTitle}({product.metadata.shortTitle ()})",
$"localizedDescription={product.metadata.localizedDescription}",
$"isoCurrencyCode={product.metadata.isoCurrencyCode}",
$"localizedPrice={product.metadata.localizedPrice}",
$"localizedPriceString={product.metadata.localizedPriceString}",
$"transactionID={product.transactionID}",
$"Receipt has={product.hasReceipt}",
$"Purchaser.Valid={Purchaser.Valid}",
$"Receipt validation={Purchaser.ValidateReceipt (product)}",
$"Possession={Purchaser.Inventory [product]}",
});
}
目録の謎
※以下は、Google Play StoreとApple App Store (Sandbox)で確認した内容です。製品でのテストではありません。
- もし、初期化の際に製品定義を渡さなかったらどうなるのでしょうか?
- その場合、製品目録は基本的に空になります。ただし、購入済みの非消費製品は取得されます。
- ストアから製品IDのカタログが得られるわけではありません。つまり、ストアに新製品を登録しただけでは、製品に組み込めないのです。
-
ProductDefinition.enabled
は、スクリプトリファレンスでは"This flag indicates whether a product should be offered for sale. It is controlled through the cloud catalog dashboard."と説明されています。- これを見る限り、ストアのダッシュボードで設定されている有効/無効状態を取得できるように読めますが、実際には常に
true
になります。 - 例え、ストアに登録されていないIDを指定した場合でも
true
です。全く役に立ちません。- ストアにない場合は、
Product.availableToPurchase
はFalse
になります。
- ストアにない場合は、
- Play Storeで無効にされている製品を購入しようとすると「原因不明の購入エラー」になります。
- App Storeで無効にされている製品でも、Sandboxでは購入できてしまいます。
- これを見る限り、ストアのダッシュボードで設定されている有効/無効状態を取得できるように読めますが、実際には常に
- つまり、以下の制約が生じます。
- ストアに登録されている未知の製品を取得することはできません。
- ストアでの状態(有効/無効)を取得することはできません。
- 購入の失敗が、ストアでの無効設定によるものと判別できません。
- その結果、以下のような使い方になります。
- ストアとは別の手段(あらかじめ組み込む、自前のサーバから取得するなど)で製品定義を保持する必要があります。
- ストアでの製品の有効/無効は、アプリの使用する製品定義に連動させます。
- 緊急時以外は、ストア独自に製品を無効化しないようにします。
購入
購入の開始
-
IStoreController.InitiatePurchase ()
にProduct
を渡すことで、購入が開始されます。
Purchaser.cs
/// <summary>課金開始</summary>
private bool purchase (Product product) {
if (product != null && product.Valid ()) {
Debug.Log ($"Purchaser.InitiatePurchase {product.definition.id} {product.metadata.localizedTitle} {product.metadata.localizedPriceString}");
controller.InitiatePurchase (product);
return true;
}
return false;
}
- 購入の要求はブロックされず、後に、結果に応じたコールバックがあります。
購入の完了
- 課金結果のコールバックでは、購入に関わる処理が全て完了したら、
PurchaseProcessingResult.Complete
を返します。- 消費タイプの場合は、消費が実行されます。
Purchaser.cs
/// <summary>課金失敗</summary>
public void OnPurchaseFailed (Product product, PurchaseFailureReason reason) {
Debug.LogError ($"Purchaser.PurchaseFailed Reason={reason}\n{product.GetProperties ()}");
}
/// <summary>課金結果 有効な消耗品なら保留、それ以外は完了とする</summary>
public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs eventArgs) {
var validated = ValidateReceipt (eventArgs.purchasedProduct);
Inventory [eventArgs.purchasedProduct] = validated;
Debug.Log ($"Purchaser.ProcessPurchase {(validated ? "Validated" : "ValidationError")} {eventArgs.purchasedProduct.GetProperties ()}");
return (validated && eventArgs.purchasedProduct.definition.type == ProductType.Consumable) ? PurchaseProcessingResult.Pending : PurchaseProcessingResult.Complete;
}
-
このコードでは、消費タイプでは
PurchaseProcessingResult.Pending
を返し、それ以外ではComplete
を返します。-
Pending
を返すと、消費は保留されます。
-
-
この保留状態は、(謎の)クラウドで保持されるためアプリが中断しても失われず、起動毎に
ProcessPurchase ()
へのコールバックが繰り返されます。- 保留状態を終わらせるには、
ProcessPurchase ()
でComplete
を返すか、別途IStoreController.ConfirmPendingPurchase (product)
を呼びます。
- 保留状態を終わらせるには、
-
Product.hasReceipt
は、起動直後に未購入または消費済みであればfalse
となり、購入完了時にはtrue
に変化します。- しかし、
Complete
を返した場合も、消費を促すConfirmPendingPurchase (product)
を行おうとも、その場ではfalse
には戻りません。 - つまり、
hasReceipt
を見て消費完了を知ることはできません。 - また、
ConfirmPendingPurchase (product)
には、結果を知らせるコールバックがありません。
- しかし、
-
従って、保留と消費の状態を判別するためには、Unity-IAPの外側で所持状態を管理する必要があります。
-
なお、非消費タイプでは、購入済みの
hasReceipt
は常にtrue
になります。
所有状態の管理
- このコードでは、
Inventory
というDictionary
派生クラスを用意して、製品所有状態を管理しています。- 初期化完了のコールバック中で初期化しています。
-
Inventory [string 製品ID]
またはInventory [Product 製品]
で真偽値を得ることができます。
Purchaser.cs
/// <summary>productID基準でProductの在庫を表現する辞書</summary>
public class Inventory : Dictionary<string, bool> {
/// <summary>Productによるアクセス</summary>
public bool this [Product product] {
get { return base [product.definition.id]; }
set { base [product.definition.id] = value; }
}
}
復元
- Appleの規約では、ユーザーがこの処理を明示的に行える必要があるのですが、これを呼ばなくてもUnity-IAPが自動的に復元をしているようなので、それ以上の意味はないように思われます。正直よく分かりません。
Purchaser.cs
/// <summary>復元</summary>
private void restore (Action<bool> onRestored = null) {
Debug.Log ("Purchaser.Restore");
Action<bool> onTransactionsRestored = success => { OnTransactionsRestored (success); onRestored?.Invoke (success); };
if (isGooglePlayStoreSelected) {
googlePlayStoreExtensions.RestoreTransactions (onTransactionsRestored);
} else if (isAppleAppStoreSelected) {
appleExtensions.RestoreTransactions (onTransactionsRestored);
}
}
Purchaser.cs
/// <summary>復元完了</summary>
private void OnTransactionsRestored (bool success) {
Debug.Log ($"Purchaser.Restored {success}");
}
コード全容
Purchaser.cs
// Copyright© tetr4lab.
using System;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Security;
/// <summary>UnityIAPを使う</summary>
namespace UnityInAppPuchaser {
#if ALLOW_UIAP
/// <summary>課金処理</summary>
public class Purchaser : IStoreListener {
#region Static
/// <summary>シングルトン</summary>
private static Purchaser instance;
/// <summary>在庫目録 製品の課金状況一覧、消費タイプは未消費を表す</summary>
public static Inventory Inventory { get; private set; }
/// <summary>有効 初期化が完了している</summary>
public static bool Valid => (instance != null && instance.valid);
/// <summary>使用不能 初期化に失敗した</summary>
public static bool Unavailable { get; private set; }
/// <summary>製品目録 初期化時の製品IDに対してストアから得た情報</summary>
public static ProductCollection Products => Valid ? instance.controller.products : null;
/// <summary>クラス初期化 製品のIDとタイプの一覧を渡す</summary>
public static void Init (IEnumerable<ProductDefinition> products) {
if (instance == null || Unavailable) {
instance = new Purchaser (products);
}
}
/// <summary>所有検証 有効なレシートが存在する</summary>
private static bool possession (Product product) {
return product.hasReceipt && Purchaser.ValidateReceipt (product);
}
/// <summary>レシート検証</summary>
public static bool ValidateReceipt (string productID) {
return (!string.IsNullOrEmpty (productID) && instance.validateReceipt (instance.controller.products.WithID (productID)));
}
/// <summary>レシート検証</summary>
public static bool ValidateReceipt (Product product) {
return (instance != null && instance.validateReceipt (product));
}
/// <summary>課金 指定製品の課金処理を開始する</summary>
public static bool Purchase (string productID) {
if (!string.IsNullOrEmpty (productID) && Valid) {
return instance.purchase (instance.controller.products.WithID (productID));
}
return false;
}
/// <summary>課金 指定製品の課金処理を開始する</summary>
public static bool Purchase (Product product) {
if (product != null && Valid) {
return instance.purchase (product);
}
return false;
}
/// <summary>保留した課金の完了 消費タイプの指定製品の保留していた消費を完了する</summary>
public static bool ConfirmPendingPurchase (string productID) {
if (!string.IsNullOrEmpty (productID) && Valid) {
return instance.confirmPendingPurchase (instance.controller.products.WithID (productID));
}
return false;
}
/// <summary>保留した課金の完了 消費タイプの指定製品の保留していた消費を完了する</summary>
public static bool ConfirmPendingPurchase (Product product) {
if (product != null && Valid) {
return instance.confirmPendingPurchase (product);
}
return false;
}
/// <summary>復元 課金情報の復元を行い、結果のコールバックを得ることができる</summary>
public static void Restore (Action<bool> onRestored = null) {
if (Valid) { instance.restore (onRestored); }
}
#endregion
/// <summary>コントローラー</summary>
private IStoreController controller;
/// <summary>拡張プロバイダ</summary>
private IExtensionProvider extensions;
/// <summary>Apple拡張</summary>
private IAppleExtensions appleExtensions;
/// <summary>Google拡張</summary>
private IGooglePlayStoreExtensions googlePlayStoreExtensions;
/// <summary>AppleAppStore</summary>
private bool isAppleAppStoreSelected;
/// <summary>GooglePlayStore</summary>
private bool isGooglePlayStoreSelected;
/// <summary>検証機構</summary>
private CrossPlatformValidator validator;
/// <summary>有効</summary>
private bool valid => (controller != null && controller.products != null);
/// <summary>コンストラクタ</summary>
private Purchaser (IEnumerable<ProductDefinition> products) {
Debug.Log ("Purchaser.Construct");
var module = StandardPurchasingModule.Instance ();
module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
isGooglePlayStoreSelected = Application.platform == RuntimePlatform.Android && module.appStore == AppStore.GooglePlay;
isAppleAppStoreSelected = Application.platform == RuntimePlatform.IPhonePlayer && module.appStore == AppStore.AppleAppStore;
validator = new CrossPlatformValidator (GooglePlayTangle.Data (), AppleTangle.Data (), Application.identifier);
var builder = ConfigurationBuilder.Instance (module);
builder.AddProducts (products);
Inventory = new Inventory { };
UnityPurchasing.Initialize (this, builder);
}
/// <summary>レシート検証</summary>
private bool validateReceipt (Product product) {
if (/*!valid ||*/ !product.hasReceipt) { return false; }
#if UNITY_EDITOR
return true;
#else
try {
var result = validator.Validate (product.receipt);
Debug.Log ($"Purchaser.validateReceipt Receipt is valid. (id:{product.definition.id})");
return true;
} catch (IAPSecurityException ex) {
Debug.LogError ($"Purchaser.validateReceipt Invalid receipt {product.definition.id}, not unlocking content. {ex}");
return false;
}
#endif
}
/// <summary>課金開始</summary>
private bool purchase (Product product) {
if (product != null && product.Valid ()) {
Debug.Log ($"Purchaser.InitiatePurchase {product.definition.id} {product.metadata.localizedTitle} {product.metadata.localizedPriceString}");
controller.InitiatePurchase (product);
return true;
}
return false;
}
/// <summary>保留した課金の完了</summary>
private bool confirmPendingPurchase (Product product) {
if (product != null && Inventory [product] && possession (product)) {
controller.ConfirmPendingPurchase (product);
Inventory [product] = false;
Debug.Log ($"Purchaser.ConfirmPendingPurchase {product.GetProperties ()}");
return true;
}
return false;
}
/// <summary>復元</summary>
private void restore (Action<bool> onRestored = null) {
Debug.Log ("Purchaser.Restore");
Action<bool> onTransactionsRestored = success => { OnTransactionsRestored (success); onRestored?.Invoke (success); };
if (isGooglePlayStoreSelected) {
googlePlayStoreExtensions.RestoreTransactions (onTransactionsRestored);
} else if (isAppleAppStoreSelected) {
appleExtensions.RestoreTransactions (onTransactionsRestored);
}
}
#region Event Handler
/// <summary>復元完了</summary>
private void OnTransactionsRestored (bool success) {
Debug.Log ($"Purchaser.Restored {success}");
}
/// <summary>iOS 'Ask to buy' 未成年者の「承認と購入のリクエスト」 承認または却下されると通常の購入イベントが発生する</summary>
private void OnDeferred (Product product) {
Debug.Log ($"Purchaser.Deferred {product.GetProperties ()}");
}
/// <summary>初期化完了</summary>
public void OnInitialized (IStoreController controller, IExtensionProvider extensions) {
Debug.Log ($"Purchaser.Initialized {controller}, {extensions}");
appleExtensions = extensions.GetExtension<IAppleExtensions> ();
appleExtensions.RegisterPurchaseDeferredListener (OnDeferred);
googlePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions> ();
this.controller = controller;
this.extensions = extensions;
foreach (var product in controller.products.all) {
if (!Inventory.ContainsKey (product)) {
Inventory [product] = possession (product);
}
}
}
/// <summary>初期化失敗</summary>
public void OnInitializeFailed (InitializationFailureReason error) {
Debug.LogError ($"Purchaser.InitializeFailed {error}");
Unavailable = true;
}
/// <summary>課金失敗</summary>
public void OnPurchaseFailed (Product product, PurchaseFailureReason reason) {
Debug.LogError ($"Purchaser.PurchaseFailed Reason={reason}\n{product.GetProperties ()}");
}
/// <summary>課金結果 有効な消耗品なら保留、それ以外は完了とする</summary>
public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs eventArgs) {
var validated = ValidateReceipt (eventArgs.purchasedProduct);
Inventory [eventArgs.purchasedProduct] = validated;
Debug.Log ($"Purchaser.ProcessPurchase {(validated ? "Validated" : "ValidationError")} {eventArgs.purchasedProduct.GetProperties ()}");
return (validated && eventArgs.purchasedProduct.definition.type == ProductType.Consumable) ? PurchaseProcessingResult.Pending : PurchaseProcessingResult.Complete;
}
/// <summary>破棄</summary>
~Purchaser () {
Debug.Log ("Purchaser.Destruct");
if (instance == this) {
instance = null;
Inventory = null;
Unavailable = false;
}
}
#endregion
}
/// <summary>製品拡張</summary>
public static class ProductExtentions {
/// <summary>製品諸元</summary>
public static string GetProperties (this Product product) {
return string.Join ("\n", new [] {
$"id={product.definition.id} ({product.definition.storeSpecificId})",
$"type={product.definition.type}",
$"enabled={product.definition.enabled}",
$"available={product.availableToPurchase}",
$"localizedTitle={product.metadata.localizedTitle}({product.metadata.shortTitle ()})",
$"localizedDescription={product.metadata.localizedDescription}",
$"isoCurrencyCode={product.metadata.isoCurrencyCode}",
$"localizedPrice={product.metadata.localizedPrice}",
$"localizedPriceString={product.metadata.localizedPriceString}",
$"transactionID={product.transactionID}",
$"Receipt has={product.hasReceipt}",
$"Purchaser.Valid={Purchaser.Valid}",
$"Receipt validation={Purchaser.ValidateReceipt (product)}",
$"Possession={Purchaser.Inventory [product]}",
});
}
/// <summary>有効性 製品がストアに登録されていることを示すが、ストアで有効かどうかには拠らない</summary>
public static bool Valid (this Product product) {
return (product.definition.enabled && product.availableToPurchase);
}
/// <summary>アプリ名を含まないタイトル</summary>
public static string shortTitle (this ProductMetadata metadata) {
return (metadata != null && !string.IsNullOrEmpty (metadata.localizedTitle)) ? (new Regex (@"\s*\(.+\)$")).Replace (metadata.localizedTitle, "") : string.Empty;
}
}
/// <summary>productID基準でProductの在庫を表現する辞書</summary>
public class Inventory : Dictionary<string, bool> {
/// <summary>Productによるアクセス</summary>
public bool this [Product product] {
get { return base [product.definition.id]; }
set { base [product.definition.id] = value; }
}
/// <summary>Productによる存在確認</summary>
public bool ContainsKey (Product product) => ContainsKey (product.definition.id);
}
#endif
}
Sample.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Purchasing;
using UnityInAppPuchaser;
public class Sample : MonoBehaviour {
[SerializeField] private Transform CatalogHolder = default;
[SerializeField] private Button RestoreButton = default;
/// <summary>製品目録</summary>
private readonly ProductDefinition [] products = new [] {
new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item1", ProductType.Consumable),
new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item2", ProductType.NonConsumable),
new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item3", ProductType.NonConsumable),
};
/// <summary>起動</summary>
private void Start () {
StartCoroutine (initPurchaser ());
}
/// <summary>開始処理</summary>
private IEnumerator initPurchaser () {
RestoreButton.interactable = false;
Purchaser.Init (products);
yield return new WaitUntil (() => Purchaser.Valid || Purchaser.Unavailable); // 初期化完了を待つ
if (Purchaser.Valid) {
Catalog.Create (CatalogHolder);
foreach (var product in Purchaser.Products.all) {
CatalogItem.Create (Catalog.ScrollRect.content, product);
}
}
RestoreButton.interactable = true;
}
/// <summary>復元ボタン</summary>
public void OnPushRestoreButton () {
if (Purchaser.Unavailable) {
StartCoroutine (initPurchaser ());
} else if (Purchaser.Valid) {
Purchaser.Restore (success => {
if (!success) { ModalDialog.Create (transform.parent, "リストアに失敗しました。\nネットワーク接続を確認してください。"); }
});
}
}
}