2026年3月21日 星期六

關閉 Android 手機 Google 自動驗證電話號碼

電信帳單多了一個不明簡訊發送的紀錄,發送到 0903449420*705,但我沒發過簡訊、手機簡訊也沒發送紀錄。

想起多年前看過網友分享  Android 手機 Google 會默默地自動發簡訊驗證電話號碼,
而且上個月我在手機新增了一個 Gmail 帳號,
帳單上的簡訊發送時間剛好在我新增幾小時後,
該不會被默默自動發簡訊驗證了吧?

但以前我已照網友的經驗分享在「Google 帳戶」->「個人資訊」->「電話號碼」將自動驗證關閉。
這個設定,確實如記憶中的已關閉自動驗證,但感覺描述又有些許不一樣。
找了早期的設定畫面:
https://www.mobile01.com/topicdetail.php?f=568&t=6483158#83389910
https://www.ptt.cc/bbs/Google/M.1639690284.A.6F8.html

比較後,發現真的不一樣:
目前(2026-03-21):(...電話號碼列表...)
-------------------------------------------------
在裝置上驗證電話號碼
為確保電話號碼與現狀一致,Google 和電信業者可定期在您的裝置上進行驗證程序。如要開啟或關閉這項功能,請前往裝置設定。
-------------------------------------------------
(...手機裝置列表...)


早期(2021):(...電話號碼列表...)
-------------------------------------------------
可使用自動驗證服務的裝置
Google 可在部分裝置上自動驗證電話號碼,持續確保您的帳戶符合現況。
-------------------------------------------------
管理自動驗證狀態
(...手機裝置列表...)


早期在手機裝置列表上方,有明確說明底下開關是「管理自動驗證狀態
目前手機裝置列表上方,這說明沒了,而中間說明多了「如要開啟或關閉這項功能,請前往裝置設定


這是不是表示這裡的設定即使關了也沒作用?
找了 Android 說明中心的描述,如下
https://support.google.com/android/answer/7521240?hl=zh-Hant
明確說明了您只能在裝置設定中管理電話號碼驗證功能。


照說明到手機的設定處
「設定」->「Google (服務)」


「所有服務」->「電話號碼認證」(在"隱私權與安全性"底下)


「自動驗證電話號碼」真的是開啟的



我關掉了,畢竟這種在手機不留痕跡的發送簡訊,真的會讓人擔心是不是被入侵了。




2026年3月15日 星期日

原來 Google 的 AI 模式回答內容,如果附上連結,代表 AI 不確定正確答案,回答內容很可能是錯的

不久前用 Google Sheets Apps Script 寫爬蟲,將抓的資料寫進試算表。
其中有個步驟是 setValues 寫入資料後
sheet.getRange(...).setValues(...)
再 setFormulas 針對某一欄加上連結
sheet.getRange(...).setFormulas(...)

本來運行的很正常,有天突然發現怎麼有的資料沒有加上連結,回想自己該段時間曾用過篩選功能。懷疑是不是篩選後隱藏的資料列,不會被 getRange() 選到。

搜尋「試算表篩選 會影響apps script sheet.getRange的結果嗎」
Google 的 AI 模式說
「答案是不會影響。sheet.getRange() 會取得該位置的所有資料,包含被篩選器隱藏的列。」


我再問「那會影響setFormulas嗎?」
Google 的 AI 模式說「答案是不會影響。就如同取得資料一樣,...」
並附了 Stack Overflow 的連結,我一直以為 Google 的 AI 模式附的連結,是 AI 回答內容的佐證、以及更深入的探討。
所以看到解決過我無數問題的 Stack Overflow,感覺很有可信度。



雖然基本上已經相信,但還是驗證一下,我開啟篩選,讓資料列隱藏,讓爬蟲跑了 一下,結果隱藏的資料列完全沒加上連結。



出乎意料,難道連 Stack Overflow 都講錯?
打開那個 Stack Overflow 網址
https://stackoverflow.com/questions/65003212/how-to-setformula-to-cell-in-row-hidden-by-filter-applied-in-google-sheet
 (How to SetFormula() to Cell in row hidden by filter applied in google sheet? - Stack Overflow)

結果是說 SetValue 可以將值成功寫入篩選後隱藏的資料列,但 SetFormula 無作用。

這跟我遇到的情況一樣,實際測試一下,測試結果如下面影片,SetFormula 對隱藏的儲存格無效:




那為什麼 Google 的 AI 模式會提供了一個跟自己答案相反的連結?

我在 Google 搜尋說明找到答案:
「...AI 模式仰賴 Google 搜尋對網路資訊的深入理解,以優質網路內容為依據,提供更真實可靠的回覆。如果無法保證回覆的品質或實用性,系統會額外提供相關網頁連結做為補充。如同所有早期階段的 AI 產品,AI 模式並非每次都能準確回答,...」
取自:https://support.google.com/websearch/answer/16011537?hl=zh-Hant
(使用 Google 搜尋的 AI 模式取得 AI 回覆 - 電腦 - Google 搜尋說明)

原來看到 Google 的 AI 模式提供連結,不是 AI 答案的佐證、參考來源。
反而是 AI 對自己答案沒信心(盡管回答的很斬釘截鐵),所以希望使用者點那些連結,自行判斷。




2026年3月10日 星期二

解決 Antigravity Failed to send 問題

問題:

使用 Antigravity 和 AI 對話時出現「Failed to send」。
開新對話,則是一直卡在 loading 轉圈圈,嘗試送出訊息,則一樣出現「Failed to send」。
版本:1.19.6


解決方式:

  1. 在程式碼編輯介面,按「Ctrl + Shift + P」,執行「Antigravity: Reset Onboarding」,重新跑一次當初安裝完軟體的初始設定。
    我執行之前,看了一下我目前的設定(rules、Browser URL Allowlist),想知道 Reset Onboarding 後,設定是否會不見。
    先講結論:最後我的設定還在,所以猜測除了過程中那些詢問的設定,對其他設定應該不會有影響。

  2. 之後就會進入初始設定,不贅述,只截圖。








  3. 前面導向瀏覽器,登入帳號,出現這個畫面後,就可以切換回 Antigravity,此時Antigravity 應該已切換到下一步的畫面。

  4. Antigravity 下一步的畫面,是關於隱私、安全,提醒避免處理高度敏感數據。
    讓我想起最初安裝 Antigravity 時,特意選了個無關緊要的舊帳號使用,用了幾天,才發現帳號裡還是有隱私資料,就另外註冊個新帳號使用,這樣最保險。建議大家目前使用這類產品,用平常沒在用的帳號,比較不用怕資料外洩。
    (前面的操作,會授權Antigravity 存取 Google 帳戶中的部分資訊,包括可能具機密性的資訊)

  5. 設定完,進到對話,已可正常使用。

  6. 原本的 Browser URL Allowlist 設定還在




其他補充:

如果有人跟我一樣換過帳號,記得解除舊帳號對Antigravity 的授權。
  1. 到「Google 帳戶」管理,點「第三方應用程式和服務」

  2. 點「Google Antigravity」

  3. 點「刪除您帳戶與Google Antigravity 之間的所有連結」





參考:


2026年3月8日 星期日

Google Colab 掛載 Google Drive

Google Colab (Colaboratory) 是 Google 提供可以免費線上執行 Python 程式的服務,雲端的 Jupyter Notebook 執行環境,自己不用安裝,只要瀏覽器就可以使用、執行 Python 程式。
而且還免費提供 GPU、TUP 使用。

進到 Colab,開的檔案叫做筆記本(Notebook),會儲存在 Google 雲端硬碟(Google Drive)裡的 Colab Notebooks 資料夾底下,副檔名 .ipynb,就像 Google文件(Google Docs)、Google試算表(Google Sheets) 存放在雲端硬碟一樣。只不過 Colab 筆記本存的是程式執行相關的東西。

Google 提供的  Colab 免費執行環境,是虛擬機,有執行時間限制
免費版 Colab 的筆記本最多可運行 12 小時,具體時間取決於資源可用性和使用模式。
而且開著網頁太久沒操作,也會中斷執行階段(執行階段可想成目前的執行環境)。

如果原本執行階段刪除了,或玩壞不得不重啟,重開後,除了筆記本上的東西還在,其他虛擬機上的東西都會重置回到初始狀態,資料若存在虛擬機空間上,不小心就會消失。


所以當有需要保留的資料,可將 Google 雲端硬碟掛載進虛擬機,當作儲存空間。

掛載方式:

  1. 按「檔案」

  2. 按「掛接雲端硬碟」

  3. 出現是否允許筆記本存取雲端硬碟,按「連線至 Google 雲端硬碟」


    其他方式掛載:前面步驟用指令執行
    from google.colab import drive
    drive.mount('/content/drive')

  4. 繼續授權操作,完成後,就可看到掛接的雲端硬碟



  5. 雲端硬碟掛接在 drive 資料夾

  6. 檔案完整路徑,可按「右鍵」-->「複製路徑」得知


  7. 將複製的 /content/drive/MyDrive/helloworld.py 路徑執行看看,可正常執行

    我們目前的目錄往上還有目錄,往上瞧瞧

    是作業系統資料夾的結構


刪除雲端硬碟授權:

若之後用不到,刪除雲端硬碟授權方式如下
  1. 到「Google 帳戶」管理,點「第三方應用程式和服務」


  2. 點「Google Drive for desktop」


  3. 點「刪除您帳戶與 Google Drive for desktop 之間的所有連結」


  4. 按「確認」完成刪除授權




2026年3月2日 星期一

Google Sheets 抓取公開資訊觀測站股利公告

在 Google Sheets 使用 Apps Script 抓取公開資訊觀測站(公告快易查)股票股利公告。


補充說明:

  • 後來才發現在彙總報表->股利分派情形可以一次抓整年,這更方便抓取。
    我一開始誤以為這是彙整最終實際股利分派的結果(因為未發前有可能公告更改),
    也誤以為未實際發放前的公告統整在彙總報表->除權息公告,沒想到前者是含未確定發放日的公告統整資料,後者是只含已確定發放日的公告統整資料,當時還想說除權息公告彙整資料那麼少,想說可能不是即時彙整。
  • 至於臺灣證券交易所 OpenAPI證券櫃檯買賣中心 OpenAPI,很久以前看過,但特性讓我不自覺忘記它們的存在(不能設查詢條件、新資料不夠新、甚至只看到幾年前的資料...)。

    後圖是櫃買 API 抓的資料



步驟:

  1. 新增一個空白工作表
    到「擴充功能」 -> 「Apps Script」

  2. 填入以下程式碼
    function fetchDividendToSheet() {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
      const lastRowS = sheet.getLastRow();
      Logger.log(`lastRowS:${lastRowS} `);
      // ══════════════════════════════════════════════
      // 設定區
      // ══════════════════════════════════════════════
      let SDATE = "115/02/10";//開始日期
      let EDATE = "115/02/10";//結束日期
      const BATCH_SIZE = 100;
    
      // ── 日期工具函式 ─────────────────────────────
      function rocToDate(rocStr) {             // "115/02/26" → Date
        const [y, m, d] = rocStr.split("/");
        return new Date(+y + 1911, +m - 1, +d);
      }
      function toRocDate(dt) {                 // Date → "115/02/26"
        return `${dt.getFullYear() - 1911}/${String(dt.getMonth() + 1).padStart(2, '0')}/${String(dt.getDate()).padStart(2, '0')}`;
      }
      function rocToAd(rocStr) {              // "115/02/26" → "2026/02/26"
        if (!rocStr) return "";
        const [y, m, d] = rocStr.split("/");
        return `${+y + 1911}/${m}/${d}`;
      }
    
      Logger.log(`本次查詢:${SDATE} ~ ${EDATE}`);
    
      // ── 呼叫 ezsearch_query ───────────────────────
      const searchResp = UrlFetchApp.fetch(
        "https://mopsov.twse.com.tw/mops/web/ezsearch_query",
        {
          method: "post",
          payload: `step=00&RADIO_CM=1&TYPEK=sii&CO_MARKET=&CO_ID=&PRO_ITEM=D02&SUBJECT=&SDATE=${SDATE}&EDATE=${EDATE}&lang=TW&AN=`,
          headers: {
            "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
            "Referer": "https://mopsov.twse.com.tw/mops/web/ezsearch",
            "X-Requested-With": "XMLHttpRequest",
            "User-Agent": "Mozilla/5.0"
          },
          muteHttpExceptions: true
        }
      );
      const records = JSON.parse(searchResp.getContentText("UTF-8")).data || [];
      Logger.log(`API 回傳共 ${records.length} 筆`);
    
      // ── F36:align='right' TD ────────────────────
      function extractF36(html) {
        const noDataMatch = html.match(/查無資料/i);
        if (noDataMatch) {
          return ["查無資料", "查無資料", "查無資料", "查無資料", "查無資料", "查無資料"];
        }
        const strip = s => s.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, '').trim();
        const rightTds = [...html.matchAll(/<TD[^>]*align=['"]right['"][^>]*>([\s\S]*?)<\/TD>/gi)]
          .map(m => strip(m[1]));
        Logger.log("F36 right TDs: " + rightTds.join(" | "));
        if (rightTds.length < 7) throw new Error(html);
        //if (rightTds.length < 7) return ["-", "-", "-", "-", "-", "-"];
        return [rightTds[0] || "--", rightTds[1] || "--", rightTds[2] || "--",
        rightTds[4] || "--", rightTds[5] || "--", rightTds[6] || "--"];
      }
    
      // ── M14:<pre> 全形冒號 ───────────────────────
      function extractM14(html) {
        const preMatch = html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
        if (!preMatch) throw new Error(html);
        //if (!preMatch) return ["-", "-", "-", "-", "-", "-"];
        const lines = preMatch[1].split('\n').map(l => l.trim()).filter(l => l);
        function findVal(kw) {
          const line = lines.find(l => l.includes(kw));
          if (!line) return "--";
          const parts = line.split(':');
          return parts.length > 1 ? parts[parts.length - 1].trim() : "---";
        }
        return [
          findVal("盈餘分配之現金股利"), findVal("法定盈餘公積發放之現金"),
          findVal("資本公積發放之現金"), findVal("盈餘轉增資配股"),
          findVal("法定盈餘公積轉增資配股"), findVal("資本公積轉增資配股"),
        ];
      }
    
      // ── 初次執行時寫入表頭(工作表為空才加)────────
      if (sheet.getLastRow() === 0) {
        sheet.appendRow([
          "發言日期", "發言時間", "公司代號", "公司簡稱", "AN_CODE", "主旨",  // A~F
          "盈餘分配之現金股利(元/股)",      // G
          "法定盈餘公積發放之現金(元/股)",  // H
          "資本公積發放之現金(元/股)",      // I
          "盈餘轉增資配股(元/股)",          // J
          "法定盈餘公積轉增資配股(元/股)",  // K
          "資本公積轉增資配股(元/股)"       // L
        ]);
      }
    
      // ── 逐筆處理 ─────────────────────────────────
      const ZERO = ["0", "0", "0", "0", "0", "0"];
      //let batch = [], totalWritten = 0;
      let batch = [], batchLinks = [], totalWritten = 0;
      let skipCount = 0, zeroCount = 0, f36Count = 0, m14Count = 0;
    
      function flushBatch() {
        if (!batch.length) return;
        const startRow = sheet.getLastRow() + 1;
        const numRows = batch.length;
        // 先寫所有純文字值
        sheet.getRange(startRow, 1, numRows, batch[0].length).setValues(batch);
        // 再對 F 欄(主旨,第6欄)補 HYPERLINK 公式
        const formulas = batchLinks.map(([url, text]) =>
          [`=HYPERLINK("${url}","${text.replace(/"/g, '""')}")`]
        );
        sheet.getRange(startRow, 6, numRows, 1).setFormulas(formulas);
        totalWritten += numRows;
        Logger.log(`📥 已寫入 ${totalWritten} 筆`);
        batch = [];
        batchLinks = [];   // ← 同步清空
      }
    
      records.forEach(rec => {
        const subject = (rec.SUBJECT || "").replace(/[\r\n]/g, " ");
        const coId = rec.COMPANY_ID || "";
        const coName = rec.COMPANY_NAME || "";
        const anCode = rec.AN_CODE || "";
        const hyperlink = rec.HYPERLINK || "";
        const cdateAd = rocToAd(rec.CDATE || "");
        const ctime = rec.CTIME || "";
    
        // 篩選
        if (subject.includes("子公司")) { skipCount++; return; }
        if (subject.includes("基準日")) { skipCount++; return; }
        if (subject.includes("交易日")) { skipCount++; return; }
        if (subject.includes("發放日")) { skipCount++; return; }
        if (!subject.includes("股利")) { skipCount++; return; }
    
        if (subject.includes("不分派股利")) {
          batch.push([cdateAd, ctime, coId, coName, anCode, subject, ...ZERO]);
          batchLinks.push([hyperlink, subject]);  // ← 新增
          zeroCount++;
    
        } else if (anCode === "F36") {
          try {
            Utilities.sleep(600);
            const html = UrlFetchApp.fetch(hyperlink, {
              headers: { "Referer": "https://mopsov.twse.com.tw", "User-Agent": "Mozilla/5.0" },
              muteHttpExceptions: true
            }).getContentText("UTF-8");
            const vals = extractF36(html);
            batch.push([cdateAd, ctime, coId, coName, anCode, subject, ...vals]);
            batchLinks.push([hyperlink, subject]);  // ← 新增
            Logger.log(`[F36] ${coId} ${coName} | ${vals[0]}`);
            f36Count++;
          } catch (e) {
            Logger.log(`[F36 錯誤] ${coId}:${e}`);
            throw e;
            //batch.push([cdateAd, ctime, coId, coName, anCode, subject, ...ZERO]);
          }
    
        } else if (anCode === "M14") {
          try {
            Utilities.sleep(600);
            const html = UrlFetchApp.fetch(hyperlink, {
              headers: { "Referer": "https://mopsov.twse.com.tw", "User-Agent": "Mozilla/5.0" },
              muteHttpExceptions: true
            }).getContentText("UTF-8");
            const vals = extractM14(html);
            batch.push([cdateAd, ctime, coId, coName, anCode, subject, ...vals]);
            batchLinks.push([hyperlink, subject]);  // ← 新增
            Logger.log(`[M14] ${coId} ${coName} | ${vals[0]}`);
            m14Count++;
          } catch (e) {
            Logger.log(`[M14 錯誤] ${coId}:${e}`);
            throw e;
            //batch.push([cdateAd, ctime, coId, coName, anCode, subject, ...ZERO]);
          }
    
        } else {
          skipCount++;
        }
    
        if (batch.length >= BATCH_SIZE) flushBatch();
      });
      flushBatch();
    
      // ── 內建 removeDuplicates(A,C,D,G~L 欄)────────
      // 保留舊資料一起判斷,不清空,只刪重複列
      // 欄位 1-based:A=1, C=3, D=4, G=7, H=8, I=9, J=10, K=11, L=12
      sheet.getDataRange().removeDuplicates([1, 3, 4, 7, 8, 9, 10, 11, 12]);
      Logger.log("去重完成");
    
      let totalChange = sheet.getLastRow() - lastRowS;
      if (lastRowS == 0) {
        totalChange--;
      }
      const msg = `✅ 完成!抓取 ${totalWritten} 筆\n去除重複資料後異動 ${totalChange} 筆\n` +
        `F36:${f36Count} M14:${m14Count} 不分派:${zeroCount} 略過:${skipCount}\n` +
        `查詢區間:${SDATE} ~ ${EDATE}`;
      Logger.log(msg);
      SpreadsheetApp.getUi().alert(msg);
    }
    
    function onOpen() {
      SpreadsheetApp.getUi()
        .createMenu("📊 股利爬蟲")
        .addItem("執行爬蟲", "fetchDividendToSheet")
        .addToUi();
    }
    
  3. 按「儲存」

  4. 選要執行的函示「fetchDividendToSheet」,按「執行」

  5. 初次執行,需要授權,按「審核權限」

  6. 按「進階」

  7. 繼續前往專案

  8. 抓取資料須「連線至外部服務」權限,
    寫入試算表須「查看、編輯、建立及刪除您的所有 Google 試算表檔案」權限。
    兩者勾選後,按「繼續」便會開始執行。

  9. 執行結果