2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

購読してたニュースが RSS 停止したので、Azure FunctionsでTeamsへ投稿してみた

Posted at

背景

ある企業のプレスリリースページを RSS を使って Teams に自動投稿していた。
ある時から、RSS 説明ページはそのままに、通知が停止してしまった。
半年近く待っても復活しなかったので、暫定対処してみた記録

結果的に、
今回は Azure Functions (Python) で JSON API を直接パース→新着差分を検出→Teams に Adaptive Card で投稿する構成を採用した。

概要

  1. RSS フィード調査(結果: 利用不可)
    1. SNS 系の通知を充実させたので、RSS は不要って判断されたのかもしれない。だが、説明ページは残ってるのが解せない :thinking:
  2. 代替案として Azure Functions でスクレイピング実装
  3. HTML 構造を解析し、最新ニュースを JSON 化
  4. Blob Storage で既読管理し、新着のみ通知
  5. Logic Apps 経由で Teams にカード送信

作業の全体フロー

1. RSS フィード調査と方針決定

結論: HTML スクレイピングで最新ニュース一覧を取得し、差分検出で新着を判定

RSS が復活したら、以前のRSS方式の方が楽なので、今回のはゴミ箱行き

2. Azure Functions でスクレイピング実装

  • News Page を調査したところ requests で JSON API を直接取得
  • ページ HTML 内の data-js-prod-newsjsonpath 属性から JSON エンドポイントを特定
  • JSON の news 配列からタイトル・日付・URL・カテゴリー・概要を抽出
  • Blob Storage に過去取得済み ID を保存し、新着のみを通知対象とする
# shared/scraper_simple.py(抜粋)
class NewsScraperSimple:
    def fetch_releases(self, max_results=10):
        # JSON API を直接取得
        json_url = 'https://example.com/.../news-article.json'
        response = requests.get(json_url)
        json_data = response.json()
        news_items = json_data.get('news', [])
        # タイトル・日付・URL を抽出
        return releases

3. Timer Trigger で定期実行

  • 平日(月〜金)午前 9 時(JST)に実行
  • function_app.pypr_checker が起動し、スクレイパー→差分検出→Teams 投稿を実行

4. Logic Apps 経由で Teams に投稿

  • Azure Functions から HTTP POST で Logic Apps を呼び出し
  • Logic Apps が Office 365 Connector で Teams チャネルに Adaptive Card を投稿
  • カードには日時・タイトル・本文・リンクボタンを含む

Adaptive Card のサンプル:

{
  "type": "AdaptiveCard",
  "version": "1.5",
  "msteams": {"width": "Full"},
  "body": [
    {
      "type": "TextBlock",
      "text": "🔔 企業ニュース",
      "weight": "Bolder",
      "size": "Large"
    },
    {
      "type": "TextBlock",
      "text": "2025年12月11日 10:00",
      "isSubtle": true,
      "spacing": "None"
    },
    {
      "type": "Container",
      "separator": true,
      "items": [
        {
          "type": "ColumnSet",
          "columns": [
            {
              "type": "Column",
              "width": "auto",
              "items": [
                {
                  "type": "TextBlock",
                  "text": "📰",
                  "size": "Large"
                }
              ]
            },
            {
              "type": "Column",
              "width": "stretch",
              "items": [
                {
                  "type": "TextBlock",
                  "text": "2025年12月11日",
                  "weight": "Bolder",
                  "color": "Accent"
                },
                {
                  "type": "TextBlock",
                  "text": "新製品発表のお知らせ - 次世代製品シリーズの開発完了に伴い、2025年第1四半期より販売を開始いたします。",
                  "wrap": true,
                  "spacing": "Small"
                }
              ]
            }
          ]
        }
      ],
      "selectAction": {
        "type": "Action.OpenUrl",
        "url": "https://example.com/news/2025/1211/001.html"
      }
    }
  ],
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json"
}

デプロイと検証の詳細

1. zip パッケージ作成

Windows 端末から Linux ホストへ出すのでリモートビルドを必須に。zip は素直に Compress-Archive で作成。

# ルート: AzureProjects\{project-name}
Remove-Item function_app.zip -ErrorAction SilentlyContinue
Compress-Archive -Path .\function_app\* -DestinationPath function_app.zip

Microsoft Learn でも、Windows で開発した Linux アプリはリモートビルドを使えと明記されている。「Windows コンピューター上で開発された Linux ベースの関数アプリに…デプロイしている。これは一般的に Python アプリ開発の場合です。」(Azure Functions のデプロイ テクノロジ)

2. config-zip デプロイ

.zip を config-zip で投入。--build-remote true でビルドをクラウド側に任せる。

az functionapp deployment source config-zip \
  --name {function-app-name} \
  --resource-group {resource-group-name} \
  --src function_app.zip \
  --build-remote true \
  --timeout 600

Zip デプロイは消費/EHP/専用プランで「既定かつおすすめのデプロイ テクノロジ」と公式に書かれている (Azure Functions のデプロイ テクノロジ).

3. HTTP トリガーで動作確認

test_trigger を叩いて Teams 送信を確認。キーはクエリ文字列 ?code= かヘッダー x-functions-key に入れる。

Invoke-RestMethod -Method Get `
  "https://{function-app-name}.azurewebsites.net/api/test_trigger?code={function-key}"

レスポンスは「pr_checker 実行完了。Teams チャネルを確認してください。」で返ってきたので、チャネル側のカード時刻と本文を確認して完了。

関数キーは平文で扱わない。ローカルではキー検証が無効になるが、Azure 上では必須(HTTP トリガーの承認レベル)。

システム構成

┌─────────────────────────────────────────────┐
│  Timer Trigger (平日 9:00 JST)              │
└────────────────┬────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────┐
│  Azure Functions (Python 3.11)              │
│  ┌───────────────────────────────────────┐  │
│  │ 1. JSON API パース                    │  │
│  │    - requests で JSON 取得           │  │
│  │    - 最新ニュース一覧を抽出          │  │
│  └───────────────────────────────────────┘  │
│  ┌───────────────────────────────────────┐  │
│  │ 2. 新着判定                           │  │
│  │    - Blob Storage から履歴取得       │  │
│  │    - 差分検出                         │  │
│  └───────────────────────────────────────┘  │
│  ┌───────────────────────────────────────┐  │
│  │ 3. Adaptive Card 生成                │  │
│  │    - Logic Apps に HTTP POST        │  │
│  └───────────────────────────────────────┘  │
└────────────────┬────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────┐
│  Logic Apps (Consumption)                   │
│  - Office 365 Connector で Teams 投稿      │
└─────────────────────────────────────────────┘

ハマりどころメモ

1. 静的 HTML スクレイピングでは取れない問題(BeautifulSoup 不要だった)

症状: 当初 BeautifulSoup で HTML を解析したが、ニュースリンクが 0 件または言語切替リンク(English, Português)のみ取得

原因: ページが JavaScript で動的にニュース一覧を生成していた。<ul class="news-list" data-js-component="...">data-js-newsjsonpath 属性があり、JSON API から非同期でデータを読み込む仕様だった。

解決策:

  • HTML ソース内に埋め込まれた JSON API エンドポイントを発見(data-js-prod-newsjsonpath 属性)
  • https://example.com/global/common/.../news-article.json を直接 GET して JSON をパース
  • news 配列から title, url, date, categories, description を取得
# 試行1: BeautifulSoup(失敗)
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, 'html.parser')
links = soup.select('ul.news-list li a')  # → 空配列(動的生成のため取得不可)

# 最終実装: JSON API 直接取得(成功)
json_url = 'https://example.com/.../news-article.json'
json_data = requests.get(json_url).json()
news_items = json_data.get('news', [])  # ← BeautifulSoup 不要

動的レンダリングサイトでは、まず DevTools の Network タブで API 通信を確認すると早い。静的スクレイピングで「リンクが取れない」「件数 0」になったら、まず JSON API の存在を疑う。

2. Playwright のインストール制限

症状: 当初 Playwright(ヘッドレス Chrome)で動的レンダリング対応を試みたが、Azure Functions Consumption プランでは Chromium バイナリサイズが大きくタイムアウト/デプロイ失敗

解決策: JSON API を見つけたため Playwright 不要に。もし将来 API が消えたら、以下の代替手段を検討:

  • Selenium Wire(軽量)
  • 外部スクレイピング API(ScrapingBee, Apify)
  • Azure Container Instances で Playwright 実行

3. ローカル HTTP テストができない問題

症状: ローカル開発環境(func start)では、HTTP トリガーの認証が無効化されるため、本番と同じアクセスキー検証ができない

対策:

  • ローカルでは http://localhost:7071/api/{function-name} で直接叩く(キー不要)
  • 本番デプロイ後に Invoke-RestMethod でキー付き URL をテスト
  • 本番環境での初回テストは test_trigger 関数を別途用意し、そこで pr_checker をプログラムから呼び出す方式を採用

Azure Functions の HTTP トリガー には「機能をローカルで実行する場合、指定された認可レベルの設定に関係なく、許可は無効になります」と明記されている。

4. WEBSITE_RUN_FROM_PACKAGE 設定の競合

症状: az functionapp deployment source config-zip 実行時に以下のエラーが発生

Error: Conflict (409): The requested operation cannot be performed 
because the site is configured to run from package.

原因の詳細:

WEBSITE_RUN_FROM_PACKAGE は ZIP パッケージからアプリを直接実行するための設定。この設定には 2 種類の値がある:

  1. WEBSITE_RUN_FROM_PACKAGE=1: wwwroot を読み取り専用にして、ZIP から直接実行
  2. WEBSITE_RUN_FROM_PACKAGE=<URL>: 外部 Blob URL から ZIP を取得して実行

この設定が有効な状態で config-zip デプロイを実行すると、以下の競合が発生:

  • config-zip の動作: ZIP を D:\home\data\SitePackages に配置し、展開して wwwroot にファイルを配置しようとする
  • WEBSITE_RUN_FROM_PACKAGE=1 の動作: ZIP を展開せず、そのまま読み取り専用でマウントする
  • 結果: デプロイシステムが「展開すべきか、マウントすべきか」で競合し、409 エラーを返す

リモートビルド (--build-remote true) を使う場合は WEBSITE_RUN_FROM_PACKAGE を削除する

公式ドキュメント (Remote builds) には「To enable the same build processes that you get with continuous integration, add SCM_DO_BUILD_DURING_DEPLOYMENT=true to your application settings in your deployment code and remove the WEBSITE_RUN_FROM_PACKAGE entirely.」と明記されている。

リモートビルドは Linux 向けパッケージ(Python, Node.js など)を Azure 側でビルドするため、ローカル ZIP とは互換性がない。

解決策:

# 1. 競合する設定を削除
az functionapp config appsettings delete \
  --name {function-app-name} \
  --resource-group {resource-group-name} \
  --setting-names WEBSITE_RUN_FROM_PACKAGE

# 2. リモートビルド付き zip デプロイを実行
az functionapp deployment source config-zip \
  --name {function-app-name} \
  --resource-group {resource-group-name} \
  --src function_app.zip \
  --build-remote true

結果: デプロイ成功後、Azure 側で以下が自動設定される:

  • SCM_DO_BUILD_DURING_DEPLOYMENT=1(ビルド有効化)
  • ENABLE_ORYX_BUILD=true(Linux ビルドエンジン有効化)
  • WEBSITE_RUN_FROM_PACKAGE設定されない(wwwroot に直接展開)

5. 通知方式の選択(Webhook vs Graph API)

当初: Teams Incoming Webhook で簡単実装を試みる

問題点:

  • 投稿者が常に「Incoming Webhook」になり、誰が投稿したか不明
  • Webhook URL 流出リスク(誰でも投稿可能)
  • 環境ごとに Webhook URL を管理する手間

最終方針: Microsoft Graph API + Managed Identity に変更

  • 投稿者を User ID で指定可能
  • Azure AD ログで監査可能
  • 環境変数で投稿先を動的変更
  • セキュリティ向上(URL 流出リスクなし)

6. その他の注意点

  • HTML 構造変更リスク: サイトリニューアルで CSS クラス名や JSON API URL が変わったら・・ :sweat:
  • 230 秒タイムアウト: HTTP 要求は 230 秒で 502 を返すので、長時間処理は非同期パターンに逃がす(Azure Functions の制限事項より)
  • トリガー同期: config-zip では自動同期だが、外部パッケージ URL 方式では syncfunctiontriggers API/ポータル再起動が必要

あとがき

RSS 停止という問題に対し、スクレイピング→差分検出→Teams 投稿の全フローを Azure Functions + Logic Apps で構築した。デプロイは zip 再生成→config-zip→HTTP テスト の 3 コマンドで完結するので、CI/CD にも載せやすい形になった。

最終的に、これを IaC 化して、社内展開できるようにしておいた。

なぜなら、クラウド勉強会用の題材にしようかなと思った為である :laughing:
これこそが今回のやる気の源泉

将来 RSS が復活したら Power Automate に戻せば済むので、それまではスクレイピングでしのぐ、ってことで。

参考リンク

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?