category: php

PHP 爬蟲 抓取 HTML 內容

on 2022-04-22

Intro

又是久違的寫爬蟲…

這次是接手大大們的 code

寫的是 PHP 版本

研究了一下寫法

才發現現在可以不使用第三方套件就可以處理了

所以這裡紀錄一下

取得 HTML 內容

  1. 使用 curl
  2. 使用 file_get_contents

curl 是我常用的方式 看了大大們的 code 才知道原來 file_get_contents 也可以取 http/https 內容…

這邊簡單貼一下兩種作法的範例

curl

function httpGet($url)
{
  $ch = curl_init();

  curl_setopt($ch,CURLOPT_URL,$url);
  curl_setopt($ch,CURLOPT_RETURNTRANSFER,true);
  curl_setopt($ch,CURLOPT_HEADER, [
    'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36'
  ]);

  $output=curl_exec($ch);

  curl_close($ch);
  return $output;
}

file_get_contents

function htmlContentGet($url) {
  $opts = [
    "http" => [
        "method" => "GET",
        "header" => "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36\r\n"
    ]
  ];

  return file_get_contents($url, false, stream_context_create($opts));
}

如果需要一些特別的 header 或是 querystring 就在各自處理

另外提一下, 用 file_get_contents 也可以抓取本地檔案, 所以也可以打開本地的 html file(至少我以前用 file_get_contents 都是用來打開本地檔案的)

refer - PHP: file_get_contents - Manual

抓取 DOM

研究了一下也有不少方式可以做

因為上面方法回來的是 string

所以接下來最重要的事情是如何擷取出想要的內容

大致有兩種方式

  1. 用 preg_replace, str_replace, strpos, substr 等方法處理
  2. 針對 DOM 轉成物件來處理

各有優缺點

就看最後決定要怎麼做

這裡針對 2 的處理方式來處理

$dom = new DOMDocument();
$dom->loadHTML($html_string, LIBXML_NOERROR);

這裡又細分兩種處理方式

  1. DOM HTML
  2. XPath

DOM HTML

$dom = new DOMDocument();
$dom->loadHTML($html_string, LIBXML_NOERROR);
$documentElement = $dom->documentElement;

XPath

$dom = new DOMDocument();
$dom->loadHTML($html_string, LIBXML_NOERROR);
$xpath = new DOMXpath($dom);

這兩種的差別就在於支援的取 DOM 的方式不同

documentElement 這個 Class 底下取得 DOM 物件的方法名稱大多和 JavaScript 的名稱一樣

可以參考以下文件

refer - PHP: DOMElement - Manual

以下列舉一些範例

$span = $documentElement->getElementsByTagName('span');

foreach( $span as $item ) {
    echo $item->textContent . "\n";
}

$img = $documentElement->getElementsByTagName('img');

foreach( $img as $item ) {
  echo $item->getAttribute('src') . "\n";
}

比較可惜的是它沒有抓取 class 的方式

這種情況就可以用取 XPath 的方式來做

基本上我個人是比較喜歡 XPath 的方式

因為相對比較好用

後續要調整也相對容易, 只要改動 XPath 就可以了

$xpath = new DOMXpath($dom);
$desc = $xpath->query("/html/body/div[2]/div[2]/div/div[1]/a/img");

foreach( $desc as $item ) {
  echo $item->getAttribute('src') . "\n";
}

refer - How to Parse HTML using PHP Native Classes

refer - PHP: DOMDocument - Manual

結語

以上的方式都只適用於 SSR 的頁面

如果內容是動態產生的一律建議直接用打 API 的方式去拿內容

Read more

PHP - 使用 urlencode rawurlencode 的差異和使用 http_build_query

on 2021-01-14

最近剛好遇到個問題就順便筆記一下(但是遇到的問題和要寫的內文無關就是了 XD)

前言

基本上在 url query string 的 value 都要做 url encode

URL encode 會用到以下標準

RFC 1738

RFC 2396

RFC 3986

主要會使用 % 字符來針對需要 escape 的字元做編碼

ex: / -> %2F, + -> %2B

但是主要又有幾個問題是基於 HTTP GETPOSTapplication/x-www-form-urlencoded 的問題

基本上在使用 HTML form 表單使用時

採用的會是把 空格轉成 +

但這些都不算是大問題

因為基於 CGI 和程式語言的實作

會把 urldeode 回來

所以在程式語言接到 query string 時都是 urldecode 回來的值

問題是在於 browser 上發出到 server 的 URL(就是打開 server 的 access log 看到進來的 Path 拉)

就是該 URL 是不是有在 query string 上是經過 urlencode 的

一但是沒有 urlencode 的

server 接進來後可能就會有問題

給個最簡單的例子

我要搜尋 iphone7+

URL 就會是 https://www.example.com/?q=iphone7%2B

如果你的 URL 是 https://www.example.com/?q=iphone7+

程式語言接到的 query string 就會變成 iphone7

當然就不是所預期的結果

正常的情況下如果是走 HTML submit

這樣當然在 input 填入 iphone7+

在 submit 時 browser 會幫你把 url encode

但是如果你是個 a link

是絕對不能用 <a href="https://www.example.com/?q=iphone7+">iphone7+</a>

得用 <a href="https://www.example.com/?q=iphone7%2B">iphone7+</a>

所以要記住一個原則

要讓 server 收到的 URL 它的 query string 得是經過 url encode 的

所以有些地方就得注意了

  1. a link 的 url query string 得是 url encode 的

  2. AJAX 在處理 API 時, 如果是 GET, query string 是要經過 url encode 的

也許有人會疑惑為啥在寫 web 時 2 的情況在使用時都沒有處理 url encode 阿

因為 library 已經幫你處理掉了

如果自己手刻一個 XMLHttpRequest 或是用 Postman 之類的工具去試試一個 GET 的 API 然後不處理 url encode 的話

就會出現問題了

那有人會疑惑說 POST 不用處理嗎?

POST 還真的不用處理

因為 POST 資料是在 content 裡面的

不是帶在 URL 上面

講了一堆廢話

還有一個要注意的重點

就是關於 +%20 的糾葛

PHP 有兩個 function

urlencode

rawurlencode

兩個 function 的實作的 RFC 是不同

簡單的說就是如下

php > echo urlencode(' ');
+
php > echo rawurlencode(' ');
%20

兩種 encode 的結果會是不一樣的

所以要注意的是用哪種 encode 就要用哪種 decode

個人偏好

因為把 轉成 + 是很古老的用法

且在一些情況底下容易出錯(但這種錯誤大多是使用上造成的錯誤), 基本上堅守著 用哪種方式 encode 就用哪種方式 decode 的原則就不太會有問題

所以當有問題發生時請先確認兩端的 encode decode 方式是否一致

因為大多數都是這個問題

所以我比較偏好 轉成 %20

遇到的問題

扯了那麼多廢話

現在才要開始講踩到的一個雷就是

在使用 http_build_query 時沒注意到的部分…

如前面提到的其實在 url query string 接進來時會自動處理 urldecode 回來原來的樣子

然後有個需求就是要做一個 redirect

redirect 有個重要的要點就是要處理 query string 決定要留哪些 query string 或過濾掉不要的 query string 後在 redirect 到新的 URL

所以身為一個懶人工程師當然是會用到 http_build_query 這個好用的 function

首先當然是要把當前的 URL 取出來處理

所以很理所當然的就是用到 parse_url 這個好用的 function

然後再把取出來的 query 用 parse_str 轉成 array

在做一些處理後就丟到 http_build_query

這樣完美的新 query string 就出來了誒

太棒了

<?php
function url_update_query($url = '', $query_string = [])
  {
    $url_data = parse_url($url);
    $query_array = [];
    if (isset($url_data['query'])) {
      $query_data = $url_data['query'];
      parse_str($query_data, $query_array);
    }
    if (!empty($query_string)) {
      foreach ($query_string as $key => $value) {
        $query_array[$key] = $value;
      }
      $url_data['query'] = http_build_query($query_array, null, '&', PHP_QUERY_RFC3986);
    }

    $scheme   = isset($url_data['scheme']) ? $url_data['scheme'] . '://' : '';
    $host     = isset($url_data['host']) ? $url_data['host'] : '';
    $port     = isset($url_data['port']) ? ':' . $url_data['port'] : '';
    $user     = isset($url_data['user']) ? $url_data['user'] : '';
    $pass     = isset($url_data['pass']) ? ':' . $url_data['pass']  : '';
    $pass     = ($user || $pass) ? "$pass@" : '';
    $path     = isset($url_data['path']) ? $url_data['path'] : '';
    $query    = isset($url_data['query']) ? '?' . $url_data['query'] : '';
    $fragment = isset($url_data['fragment']) ? '#' . $url_data['fragment'] : '';

    return $scheme . $user . $pass . $host . $port . $path . $query . $fragment;
  }

然後上線後

就開始收到 error 了…

一開始以為是處理的 function 有問題

這邊我的作法是把 $_SERVER['QUERY_STRING'] 接近來這個 function 處理

$_SERVER['QUERY_STRING'] 進來的 query string 是以 STRING 呈現的全部 query string

沒有處理 urldecode

但是 parse_str 會處理 url encode(這邊 PHP 會自動把 +%20 轉成空白)

然後 http_build_query 會再組成 query string 時做 url encode

所以這過程是沒有 url decode 和 encode 的問題

雖然使用情境不是上述的例子就是了(但也是一樣是 query string 相關的)

那為啥會寫一大串是因為我都照了上面的情況排查了一遍發現都不是上述的問題…

最後去檢查 access log 發現是有的過來的 log 根本就是沒有 url encode…

只是目前還沒找出來為何會有沒有 url encode 的來源

因為來源的連結全部都是有處理過的…

而且也不是 100% 的 log 都是沒處理的…

又因為不是重要的頁面所以當初 log 沒有加 User Agent(因為我們有做 log filter, 為了可以倒進去 elasticsearch 去用 kibana 看, 所以只有 filter 重要的值出來)

但是為了良好的解決問題

這邊就採用了另一個處理方式

解決方案

我們遇到的問題在於因為有部分的 query string 的值是使用 base64

在使用 base64 可能會產生 + 等需要進行百分號編碼處理的字元

但正常來說我們在使用時會有 url encode 處理 query string 然後是給使用者這樣的連結

但是就是遇到部分的情況造成在 request 過來時是沒有 url encode 的情況

就產生問題了…

所以就採用 base64url

base64url 是基於 RFC 4648 裡面有提到針對部分情況例如 URL 或是某些系統的檔案名稱無法有效處理需要做百分號編碼的情況額外會再做一次編碼解碼

主要的處理有

+ => -

/ => _

還有基於長度編碼

= 填滿長度

這樣就解決掉一些 request 進來時沒有被有效的 url encode 的問題

算是一個有趣的經歷

因為這在 QA 和 RD 測試時都沒有遇到

真的上線後才遇到…

還好不是百分之百發生的情況…

Refer - String based data encoding: Base64 vs Base64url

Refer - Base64的介绍以及Base64URL介绍

Refer - 百分號編碼

Read more