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. 執行結果