阿川私房教材:學程式,拿 offer!

63 個專案實戰,直接上手!
無需補習,按步驟打造你的面試作品。

立即解鎖你的轉職秘笈

目錄

介紹

在不斷發展的 Web 開發領域,有效收集、處理和顯示外部來源資料的能力變得越來越有價值。無論是市場研究、競爭分析或客戶洞察,網路抓取在釋放網路資料的巨大潛力方面都發揮著至關重要的作用。

這篇部落格文章介紹了建立強大的 Next.js 應用程式的綜合指南,該應用程式旨在從領先的旅行搜尋引擎之一 Kayak 抓取航班資料。透過利用 Next.js 的強大功能以及 BullMQ、Redis 和 Puppeteer 等現代技術。

技術堆疊

特徵

  • 🚀 帶有 Tailwind CSS 的 Next.js 14 應用程式目錄 - 體驗由最新 Next.js 14 提供支援的時尚現代的 UI,並使用 Tailwind CSS 進行設計,以實現完美的外觀和感覺。

  • 🔗 API 路由和伺服器操作 - 深入研究與 Next.js 14 的 API 路由和伺服器操作的無縫後端集成,確保高效的資料處理和伺服器端邏輯執行。

  • 🕷 使用 Puppeteer Redis 和 BullMQ 進行抓取 - 利用 Puppeteer 的強大功能進行進階 Web 抓取,並使用 Redis 和 BullMQ 管理佇列和作業以實現強大的後端操作。

  • 🔑 用於身份驗證和授權的 JWT 令牌 - 使用 JWT 令牌保護您的應用程式,為整個平台提供可靠的身份驗證和授權方法。

  • 💳 支付網關 Stripe - 整合 Stripe 進行無縫支付處理,為預訂旅行、航班和飯店提供安全、輕鬆的交易。

  • ✈️ 使用 Stripe 支付網關預訂旅行、航班和飯店 - 使用我們的 Stripe 支援的支付系統,讓您的旅遊預訂體驗變得輕鬆。

  • 📊 從多個網站抓取即時資料 - 從多個來源抓取即時資料,保持領先,讓您的應用程式更新最新資訊。

  • 💾 使用 Prisma 將抓取的資料儲存在 PostgreSQL 中 - 利用 PostgreSQL 和 Prisma 高效儲存和管理抓取的資料,確保可靠性和速度。

  • 🔄 用於狀態管理的 Zustand - 透過 Zustand 簡化狀態邏輯並增強效能,在您的應用程式中享受流暢且可管理的狀態管理。

  • 😈 該應用程式的最佳功能 - 使用 Bright Data 的抓取瀏覽器抓取不可抓取的資料。

抓取瀏覽器迷因

Bright Data的抓取瀏覽器為我們提供了自動驗證碼解決功能,可以幫助我們抓取不可抓取的資料。

第 1 步:設定 Next.js 應用程式

  1. 建立 Next.js 應用程式:首先建立一個新的 Next.js 應用程式(如果您還沒有)。您可以透過在終端機中執行以下命令來完成此操作:
npx create-next-app@latest booking-app
  1. 導航到您的應用程式目錄:變更為您新建立的應用程式目錄:
cd booking-app

步驟2:安裝所需的軟體包

您需要安裝多個軟體包,包括 Redis、BullMQ 和 Puppeteer Core。執行以下命令來安裝它們:

npm install ioredis bullmq puppeteer-core
  • ioredis是 Node.js 的強大 Redis 用戶端,支援與 Redis 進行通訊。

  • bullmq以 Redis 作為後端來管理作業和訊息佇列。

  • puppeteer-core可讓您控制外部瀏覽器以進行抓取。

步驟3:設定Redis連接

在適當的目錄(例如lib/ )中建立一個檔案(例如redis.js )來配置 Redis 連線:

// lib/redis.js
import Redis from 'ioredis';

// Use REDIS_URL from environment or fallback to localhost
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const connection = new Redis(REDIS_URL);

export { connection };

步驟4:配置BullMQ佇列

透過在 Redis 配置所在的相同目錄中建立另一個檔案(例如, queue.js )來設定 BullMQ 佇列:

// lib/queue.js
import { Queue } from 'bullmq';
import { connection } from './redis';

export const importQueue = new Queue('importQueue', {
  connection,
  defaultJobOptions: {
    attempts: 2,
    backoff: {
      type: 'exponential',
      delay: 5000,
    },
  },
});

第 5 步:Next.js 儀器設置

Next.js 允許偵測,可以在 Next.js 配置中啟用。您還需要建立一個用於作業處理的工作文件。

1.在 Next.js 中啟用 Instrumentation :將以下內容新增至next.config.js以啟用 Instrumentation:

// next.config.js
module.exports = {
  experimental: {
    instrumentationHook: true,
  },
};

2.建立用於作業處理的 Worker :在您的應用程式中,建立一個檔案 ( instrumentation.js ) 來處理作業處理。該工作人員將使用 Puppeteer 來執行抓取任務:

// instrumentation.js
export const register = async () => {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { Worker } = await import('bullmq');
    const puppeteer = await import('puppeteer-core');
    const { connection } = await import('./lib/redis');
    const { importQueue } = await import('./lib/queue');

    new Worker('importQueue', async (job) => {
      // Job processing logic with Puppeteer goes here
    }, {
      connection,
      concurrency: 10,
      removeOnComplete: { count: 1000 },
      removeOnFail: { count: 5000 },
    });
  }
};

第 6 步:設定 Bright Data 的抓取瀏覽器

在設定 Bright 資料抓取瀏覽器之前,我們先來談談什麼是抓取瀏覽器。

Bright Data 的抓取瀏覽器是什麼?

Bright Data 的抓取瀏覽器是一款用於自動網頁抓取的尖端工具,旨在與 Puppeteer、Playwright 和 Selenium 無縫整合。它提供了一套網站解鎖功能,包括代理輪換、驗證碼解決等,以提高抓取效率。它非常適合需要互動的複雜網頁抓取,透過在 Bright Data 基礎架構上託管無限的瀏覽器會話來實現可擴展性。如欲了解更多詳情,請造訪光明資料

明亮的資料抓取瀏覽器

<a id="steps-to-set-up-bright-datas-scraping-browser"></a>

第 1 步:導覽至 Bright Data 網站

首先造訪Brightdata.com 。這是您存取 Bright Data 提供的豐富網頁抓取資源和工具的入口。

光明資料首頁

第 2 步:建立帳戶

造訪 Bright Data 網站後,註冊並建立一個新帳戶。系統將提示您輸入基本資訊以啟動並執行您的帳戶。

登入/註冊 Bright Data

第 3 步:選擇您的產品

在產品選擇頁面上,尋找代理商和抓取基礎設施產品。本產品專為滿足您的網路抓取需求而設計,提供強大的資料擷取工具和功能。

光明資料產品

第 4 步:新增代理

在「代理程式和抓取基礎設施」頁面中,您會找到一個「新增按鈕」。點擊此按鈕開始將新的抓取瀏覽器新增到您的工具包的過程。

新代理

第五步:選擇抓取瀏覽器

將出現一個下拉列表,您應該從中選擇抓取瀏覽器選項。這告訴 Bright Data 您打算設定一個新的抓取瀏覽器環境。

選擇抓取瀏覽器

第 6 步:為您的抓取瀏覽器命名

為您的新抓取瀏覽器指定一個唯一的名稱。這有助於稍後辨識和管理它,特別是如果您計劃對不同的抓取專案使用多個瀏覽器。

抓取瀏覽器名稱

步驟7:新增瀏覽器

命名您的瀏覽器後,按一下「新增」按鈕。此操作完成了新的抓取瀏覽器的建立。

新增抓取瀏覽器

第 8 步:查看您的抓取瀏覽器詳細訊息

新增抓取瀏覽器後,您將被導向到一個頁面,您可以在其中查看新建立的抓取瀏覽器的所有詳細資訊。這些資訊對於整合和使用至關重要。

抓取瀏覽器詳細訊息

第 9 步:存取程式碼和整合範例

尋找“查看程式碼和整合範例”按鈕。點擊此按鈕將為您提供如何跨多種程式語言和程式庫整合和使用抓取瀏覽器的全面視圖。對於希望自訂抓取設定的開發人員來說,此資源非常寶貴。

程式碼和整合範例按鈕

第 10 步:整合您的抓取瀏覽器

最後,複製 SRS_WS_ENDPOINT 變數。這是一條關鍵訊息,您需要將其整合到原始程式碼中,以便您的應用程式能夠與您剛剛設定的抓取瀏覽器進行通訊。

抓取瀏覽器端點

透過遵循這些詳細步驟,您已在 Bright Data 平台中成功建立了一個抓取瀏覽器,準備好處理您的網頁抓取任務。請記住,Bright Data 提供廣泛的文件和支持,幫助您最大限度地提高抓取專案的效率和效果。無論您是在收集市場情報、進行研究還是監控競爭格局,新設定的抓取瀏覽器都是資料收集庫中的強大工具。

第 7 步:使用 Puppeteer 實作抓取邏輯

從我們上次設定用於抓取航班資料的 Next.js 應用程式的地方開始,下一個關鍵步驟是實現實際的抓取邏輯。此過程涉及利用 Puppeteer 連接到瀏覽器實例、導航到目標 URL(在我們的範例中為 Kayak)並抓取必要的飛行資料。提供的程式碼片段概述了實現此目標的複雜方法,與我們先前建立的 BullMQ 工作設定無縫整合。讓我們分解這個抓取邏輯的元件,並了解它如何適合我們的應用程式。

建立與瀏覽器的連接

我們抓取過程的第一步是透過 Puppeteer 建立與瀏覽器的連線。這是透過利用puppeteer.connect方法來完成的,該方法使用 WebSocket 端點 ( SBR_WS_ENDPOINT ) 連接到現有的瀏覽器實例。此環境變數應設定為您正在使用的抓取瀏覽器服務的 WebSocket URL,例如 Bright Data:

const browser = await puppeteer.connect({
  browserWSEndpoint: SBR_WS_ENDPOINT,
});

開啟新頁面並導航到目標 URL

連線後,我們在瀏覽器中建立一個新頁面並導航到作業資料中指定的目標 URL。此 URL 是我們打算從中抓取航班資料的特定 Kayak 搜尋結果頁面:

const page = await browser.newPage();
await page.goto(job.data.url);

抓取航班資料

我們邏輯的核心在於從頁面中抓取航班資料。我們透過使用page.evaluate來實現這一點,這是一種 Puppeteer 方法,允許我們在瀏覽器上下文中執行腳本。在此腳本中,我們等待必要的元素加載,然後繼續收集航班資訊:

  • Flight Selector :我們以.nrc6-wrapper類別為目標元素,其中包含航班詳細資訊。

  • 資料擷取:對於每個航班元素,我們提取詳細訊息,例如航空公司徽標、出發和到達時間、航班持續時間、航空公司名稱和價格。出發和到達時間經過清理,以刪除最後不必要的數值,確保我們準確地捕捉時間。

  • 價格處理:價格在刪除所有非數字字元後提取為整數,確保其可用於數值運算或比較。

擷取的資料被建構成飛行物件陣列,每個物件都包含上述詳細資訊:

const scrappedFlights = await page.evaluate(async () => {
  // Data extraction logic
  const flights = [];
  // Process each flight element
  // ...
  return flights;
});

錯誤處理和清理

我們的抓取邏輯被包裝在一個 try-catch 區塊中,以在抓取過程中優雅地處理任何潛在的錯誤。無論結果如何,我們都會確保瀏覽器在finally區塊中正確關閉,從而保持資源效率並防止潛在的記憶體洩漏:

try {
  // Scraping logic
} catch (error) {
  console.log({ error });
} finally {
  await browser.close();
  console.log("Browser closed successfully.");
}

整個程式碼

const SBR_WS_ENDPOINT = process.env.SBR_WS_ENDPOINT;

export const register = async () => {

  if (process.env.NEXT_RUNTIME === "nodejs") {

    const { Worker } = await import("bullmq");
    const puppeteer = await import("puppeteer");
    const { connection } = await import("./lib/redis");
    const { importQueue } = await import("./lib/queue");

    new Worker(
      "importQueue",
      async (job) => {
        const browser = await puppeteer.connect({
          browserWSEndpoint: SBR_WS_ENDPOINT,
        });

        try {
          const page = await browser.newPage();

          console.log("in flight scraping");
          console.log("Connected! Navigating to " + job.data.url);
          await page.goto(job.data.url);
          console.log("Navigated! Scraping page content...");
          const scrappedFlights = await page.evaluate(async () => {
            await new Promise((resolve) => setTimeout(resolve, 5000));

            const flights = [];

            const flightSelectors = document.querySelectorAll(".nrc6-wrapper");

            flightSelectors.forEach((flightElement) => {
              const airlineLogo = flightElement.querySelector("img")?.src || "";
              const [rawDepartureTime, rawArrivalTime] = (
                flightElement.querySelector(".vmXl")?.innerText || ""
              ).split(" – ");

              // Function to extract time and remove numeric values at the end
              const extractTime = (rawTime: string): string => {
                const timeWithoutNumbers = rawTime
                  .replace(/[0-9+\s]+$/, "")
                  .trim();
                return timeWithoutNumbers;
              };

              const departureTime = extractTime(rawDepartureTime);
              const arrivalTime = extractTime(rawArrivalTime);
              const flightDuration = (
                flightElement.querySelector(".xdW8")?.children[0]?.innerText ||
                ""
              ).trim();

              const airlineName = (
                flightElement.querySelector(".VY2U")?.children[1]?.innerText ||
                ""
              ).trim();

              // Extract price
              const price = parseInt(
                (
                  flightElement.querySelector(".f8F1-price-text")?.innerText ||
                  ""
                )
                  .replace(/[^\d]/g, "")
                  .trim(),
                10
              );

              flights.push({
                airlineLogo,
                departureTime,
                arrivalTime,
                flightDuration,
                airlineName,
                price,
              });
            });

            return flights;
          });
        } catch (error) {
          console.log({ error });
        } finally {
          await browser.close();
          console.log("Browser closed successfully.");
        }
      },
      {
        connection,
        concurrency: 10,
        removeOnComplete: { count: 1000 },
        removeOnFail: { count: 5000 },
      }
    );
  }
};

步驟8:航班搜尋功能

基於我們的航班資料抓取功能,讓我們將全面的航班搜尋功能整合到我們的 Next.js 應用程式中。此功能將為使用者提供一個動態介面,透過指定出發地、目的地和日期來搜尋航班。利用強大的 Next.js 框架以及現代 UI 庫和狀態管理,我們建立了引人入勝且響應迅速的航班搜尋體驗。

航班搜尋功能的關鍵組成部分

  1. 動態城市選擇:此功能包括來源和目的地輸入的自動完成功能,由預先定義的城市機場程式碼清單提供支援。當使用者輸入時,應用程式會過濾並顯示匹配的城市,透過更輕鬆地尋找和選擇機場來增強用戶體驗。

  2. 日期選擇:使用者可以透過日期輸入選擇預期的航班日期,為規劃旅行提供彈性。

  3. 抓取狀態監控:啟動抓取作業後,應用程式透過定期 API 呼叫來監控作業的狀態。這種非同步檢查允許應用程式使用抓取過程的狀態更新 UI,確保使用者了解進度和結果。

航班搜尋元件的完整程式碼

"use client";
import { useAppStore } from "@/store";
import { USER_API_ROUTES } from "@/utils/api-routes";
import { cityAirportCode } from "@/utils/city-airport-codes";
import { Button, Input, Listbox, ListboxItem } from "@nextui-org/react";
import axios from "axios";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useEffect, useRef, useState } from "react";
import { FaCalendarAlt, FaSearch } from "react-icons/fa";

const SearchFlights = () => {
  const router = useRouter();
  const { setScraping, setScrapingType, setScrappedFlights } = useAppStore();
  const [loadingJobId, setLoadingJobId] = useState<number | undefined>(undefined);
  const [source, setSource] = useState("");
  const [sourceOptions, setSourceOptions] = useState<
    { city: string; code: string; }[]
  >([]);
  const [destination, setDestination] = useState("");
  const [destinationOptions, setDestinationOptions] = useState<
    { city: string; code: string; }[]
  >([]);
  const [flightDate, setFlightDate] = useState(() => {
    const today = new Date();
    return today.toISOString().split("T")[0];
  });

  const handleSourceChange = (query: string) => {
    const matchingCities = Object.entries(cityAirportCode)
      .filter(([, city]) => city.toLowerCase().includes(query.toLowerCase()))
      .map(([code, city]) => ({ code, city }))
      .splice(0, 5);

    setSourceOptions(matchingCities);
  };

  const destinationChange = (query: string) => {
    const matchingCities = Object.entries(cityAirportCode)
      .filter(([, city]) => city.toLowerCase().includes(query.toLowerCase()))
      .map(([code, city]) => ({ code, city }))
      .splice(0, 5);

    setDestinationOptions(matchingCities);
  };

  const startScraping = async () => {
    if (source && destination && flightDate) {
      const data = await axios.get(`${USER_API_ROUTES.FLIGHT_SCRAPE}?source=${source}&destination=${destination}&date=${flightDate}`);
      if (data.data.id) {
        setLoadingJobId(data.data.id);
        setScraping(true);
        setScrapingType("flight");
      }
    }
  };

  useEffect(() => {
    if (loadingJobId) {
      const checkIfJobCompleted = async () => {
        try {
          const response = await axios.get(`${USER_API_ROUTES.FLIGHT_SCRAPE_STATUS}?jobId=${loadingJobId}`);
          if (response.data.status) {
            set

ScrappedFlights(response.data.flights);
            clearInterval(jobIntervalRef.current);
            setScraping(false);
            setScrapingType(undefined);
            router.push(`/flights?data=${flightDate}`);
          }
        } catch (error) {
          console.log(error);
        }
      };
      jobIntervalRef.current = setInterval(checkIfJobCompleted, 3000);
    }

    return () => clearInterval(jobIntervalRef.current);
  }, [loadingJobId]);

  return (
    <div className="h-[90vh] flex items-center justify-center">
      <div className="absolute left-0 top-0 h-[100vh] w-[100vw] max-w-[100vw] overflow-hidden overflow-x-hidden">
        <Image src="/flight-search.png" fill alt="Search" />
      </div>
      <div className="absolute h-[50vh] w-[60vw] flex flex-col gap-5">
        {/* UI and functionality for flight search */}
      </div>
    </div>
  );
};

export default SearchFlights;

步驟9:航班搜尋頁面UI

航班搜尋頁面

顯示航班搜尋結果

成功抓取飛行資料後,下一個關鍵步驟是以使用者友善的方式將這些結果呈現給使用者。 Next.js 應用程式中的 Flights 元件就是為此目的而設計的。

"use client";

import { useAppStore } from "@/store";
import { USER_API_ROUTES } from "@/utils/api-routes";
import { Button } from "@nextui-org/react";
import axios from "axios";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { FaChevronLeft } from "react-icons/fa";
import { MdOutlineFlight } from "react-icons/md";

const Flights = () => {
  const router = useRouter();
  const searchParams = useSearchParams();
  const date = searchParams.get("date");
  const { scrappedFlights, userInfo } = useAppStore();
  const getRandomNumber = () => Math.floor(Math.random() * 41);

  const bookFLight = async (flightId: number) => {};

  return (
    <div className="m-10 px-[20vw] min-h-[80vh]">
      <Button
        className="my-5"
        variant="shadow"
        color="primary"
        size="lg"
        onClick={() => router.push("/search-flights")}
      >
        <FaChevronLeft /> Go Back
      </Button>
      <div className="flex-col flex gap-5">
        {scrappedFlights.length === 0 && (
          <div className="flex items-center justify-center py-5 px-10 mt-10 rounded-lg text-red-500 bg-red-100 font-medium">
            No Flights found.
          </div>
        )}
        {scrappedFlights.map((flight: any) => {
          const seatsLeft = getRandomNumber();
          return (
            <div
              key={flight.id}
              className="grid grid-cols-12 border bg-gray-200 rounded-xl font-medium drop-shadow-md"
            >
              <div className="col-span-9 bg-white rounded-l-xl p-10 flex flex-col gap-5">
                <div className="grid grid-cols-4 gap-4">
                  <div className="flex flex-col gap-3 font-medium">
                    <div>
                      <div className="relative w-20 h-16">
                        <Image src={flight.logo} alt="airline name" fill />
                      </div>
                    </div>
                    <div>{flight.name}</div>
                  </div>
                  <div className="col-span-3 flex justify-between">
                    <div className="flex flex-col gap-2">
                      <div className="text-blue-600">From</div>
                      <div>
                        <span className="text-3xl">
                          <strong>{flight.departureTime}</strong>
                        </span>
                      </div>
                      <div>{flight.from}</div>
                    </div>
                    <div className="flex flex-col items-center justify-center gap-2">
                      <div className="bg-violet-100 w-max p-3 text-4xl text-blue-600 rounded-full">
                        <MdOutlineFlight />
                      </div>
                      <div>
                        <span className="text-lg">
                          <strong>Non-stop</strong>
                        </span>
                      </div>
                      <div>{flight.duration}</div>
                    </div>

                    <div className="flex flex-col gap-2">
                      <div className="text-blue-600">To</div>
                      <div>
                        <span className="text-3xl">
                          <strong>{flight.arrivalTime}</strong>
                        </span>
                      </div>
                      <div>{flight.to}</div>
                    </div>
                  </div>
                </div>
                <div className="flex justify-center gap-10 bg-violet-100 p-3 rounded-lg">
                  <div className="flex">
                    <span>Airplane  </span>
                    <span className="text-blue-600 font-semibold">
                      Boeing 787
                    </span>
                  </div>
                  <div className="flex">
                    <span>Travel Class:  </span>
                    <span className="text-blue-600 font-semibold">Economy</span>
                  </div>
                </div>
                <div className="flex justify-between font-medium">
                  <div>
                    Refundable <span className="text-blue-600"> $5 ecash</span>
                  </div>
                  <div
                    className={`${
                      seatsLeft > 20 ? "text-green-500" : "text-red-500"
                    }`}
                  >
                    Only {seatsLeft} Seats Left
                  </div>
                  <div className="cursor-pointer">Flight Details</div>
                </div>
              </div>
              <div className="col-span-3 bg-violet-100 rounded-r-xl h-full flex flex-col items-center justify-center gap-5">
                <div>
                  <div>
                    <span className="line-through font-light">
                      ${flight.price + 140}
                    </span>
                  </div>
                  <div className="flex items-center gap-2">
                    <span className="text-5xl font-bold">${flight.price}</span>
                    <span className="text-blue-600">20% OFF</span>
                  </div>
                </div>
                <Button
                  variant="ghost"
                  radius="full"
                  size="lg"
                  color="primary"
                  onClick={() => {
                    if (userInfo) bookFLight(flight.id);
                  }}
                >
                  {userInfo ? "Book Now" : "Login to Book"}
                </Button>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default Flights;

航班搜尋結果

航班搜尋結果

探索完整的指南和程式碼庫

上面共享的部分和程式碼片段僅代表使用 Next.js 建立強大的航班資料抓取和搜尋應用程式所需的完整功能和程式碼的一小部分。為了掌握這個專案的全部內容,包括高級功能、優化和最佳實踐,我邀請您更深入地研究我的線上綜合資源。

在 YouTube 上觀看詳細說明

有關引導您完成此應用程式的開發過程、編碼細微差別和功能的逐步影片指南,請觀看我的 YouTube 影片。本教程旨在讓您更深入地了解這些概念,讓您按照自己的步調進行操作並獲得對 Next.js 應用程式開發的寶貴見解。

https://www.youtube.com/watch?v=ZWVhk0fxHM0

在 GitHub 上探索完整程式碼

如果您渴望探索完整的程式碼,請造訪我的 GitHub 儲存庫。在那裡,您將找到完整的程式碼庫,包括讓該應用程式在您自己的電腦上執行所需的所有元件、實用程式和設定說明。

https://github.com/koolkishan/nextjs-travel-planner

結論

使用 Next.js 建立飛行資料抓取和搜尋工具等綜合應用程式展示了現代 Web 開發工具和框架的強大功能和多功能性。無論您是希望提高技能的經驗豐富的開發人員,還是渴望深入 Web 開發的初學者,這些資源都是為您的旅程量身定制的。在 YouTube 上觀看詳細教程,在 GitHub 上探索完整程式碼,並加入對話以增強您的開發專業知識並為充滿活力的開發者社群做出貢獻。


原文出處:https://dev.to/kishansheth/nextjs-14-booking-app-with-live-data-scraping-using-scraping-browser-610


共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。

阿川私房教材:學程式,拿 offer!

63 個專案實戰,直接上手!
無需補習,按步驟打造你的面試作品。

立即解鎖你的轉職秘笈