01Check Word and PowerPoint files together and write one Excel workbookVerified

Checks the .docx and .pptx files in a folder against one term list and writes the candidates and a per-file count to a single Excel workbook, with an AutoFilter so reviewers can narrow the list by term or file.

Approach and steps
  1. Split text extraction by file type (docx_texts and pptx_texts) and pick one through the READERS dictionary
  2. Read paragraphs and table cells from Word, and shapes with a text frame plus speaker notes from PowerPoint
  3. Normalize with NFKC and casefold before matching, so full-width and half-width forms match
  4. Use openpyxl to create a hits sheet and a summary sheet, and set an AutoFilter over the hits header row
PythonRuns locally
"""Check every .docx and .pptx in a folder against a style list and write one Excel workbook for review."""
import csv
import unicodedata
from collections import Counter
from pathlib import Path

from docx import Document
from openpyxl import Workbook
from pptx import Presentation

FOLDER = Path("samples")


def normalize(text):
    return unicodedata.normalize("NFKC", text).casefold()


def load_terms(path):
    with open(path, encoding="utf-8-sig", newline="") as f:
        return [(r["term"], r["action"], normalize(r["term"])) for r in csv.DictReader(f) if r["term"].strip()]


def docx_texts(path):
    doc = Document(path)
    for i, p in enumerate(doc.paragraphs, 1):
        yield "paragraph %d" % i, p.text
    for ti, table in enumerate(doc.tables, 1):
        for ri, row in enumerate(table.rows, 1):
            for ci, cell in enumerate(row.cells, 1):
                yield "table %d r%d c%d" % (ti, ri, ci), cell.text


def pptx_texts(path):
    for n, slide in enumerate(Presentation(path).slides, 1):
        for shape in slide.shapes:
            if shape.has_text_frame:
                yield "slide %d %s" % (n, shape.name), shape.text_frame.text
        if slide.has_notes_slide:
            yield "slide %d notes" % n, slide.notes_slide.notes_text_frame.text


READERS = {".docx": docx_texts, ".pptx": pptx_texts}


def check(folder, terms):
    rows = []
    for path in sorted(folder.iterdir()):
        reader = READERS.get(path.suffix.lower())
        if reader is None or path.name.startswith("~$"):
            continue
        for where, text in reader(path):
            key = normalize(text)
            rows += [[path.name, where, term, action, text.strip()] for term, action, k in terms if k in key]
    return rows


def write_book(rows, out="out_style_check.xlsx"):
    wb = Workbook()
    ws = wb.active
    ws.title = "hits"
    ws.append(["file", "where", "term", "action", "text"])
    for r in rows:
        ws.append(r)
    ws.auto_filter.ref = "A1:E%d" % (len(rows) + 1)
    summary = wb.create_sheet("summary")
    summary.append(["file", "hits"])
    for name, n in sorted(Counter(r[0] for r in rows).items()):
        summary.append([name, n])
    wb.save(out)


def main():
    rows = check(FOLDER, load_terms(FOLDER / "style_terms.csv"))
    write_book(rows)
    print("hits:", len(rows))
    for name, n in sorted(Counter(r[0] for r in rows).items()):
        print(" ", name, n)


if __name__ == "__main__":
    main()
Output(2026-09-12)
hits: 11
  deck_a.pptx 4
  doc_a.docx 7
Example instruction for Copilot

Write Python that matches the .docx files (paragraphs and table cells) and .pptx files (text frames and speaker notes) in a folder against the terms in style_terms.csv, and writes the result with openpyxl to one workbook: a hits sheet with an AutoFilter and a summary sheet with counts per file. Only read the original files.

Caution

Text inside PowerPoint tables and grouped shapes is not read in this example (see the PowerPoint × Python page for how). According to the openpyxl documentation, openpyxl only configures filters; they are applied in applications such as Excel. The list is preparation; the people responsible decide what to change after reading the context.

Availability
Uses python-docx, python-pptx and openpyxl in a local virtual environment.
Requires
python-docx 1.2.0, python-pptx 1.0.2, openpyxl 3.1.5
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, python-docx 1.2.0, python-pptx 1.0.2, openpyxl 3.1.5)
Source
openpyxl, "Using filters and sorts"
python-pptx, "Working with text"
python-docx, "Document objects (API)"
Verified
2026-09-12 (v1)
Supporting passages from the sources
It’s possible to filter single range of values in a worksheet by adding an autofilter.
Filters and sorts can only be configured by openpyxl but will need to be applied in applications like Excel.
All the text in a shape is contained in its text frame.
The Paragraph instances in the document, in document order.

02Pick requests needing follow-up from the ledger, one sheet per ownerVerified

Picks returned requests and requests left received or in progress for too long from the ledger, and writes a new workbook with one sheet per owner, so each owner only needs to look at their own sheet.

Approach and steps
  1. Put the reference date (AS_OF) and the day limit (LIMIT_DAYS) in constants at the top; a fixed reference date makes the result reproducible
  2. Convert request dates to datetimes and add a days-elapsed column from the reference date
  3. Flag rows that were returned, or are still received or in progress beyond the limit, and filter to them
  4. Inside a pandas.ExcelWriter with-block, write one sheet per owner with to_excel
PythonRuns locally
"""List returned and overdue requests from the ledger, one sheet per owner, in a new workbook."""
import pandas as pd

AS_OF = pd.Timestamp("2026-09-12")  # fixed so the result can be reproduced; use pd.Timestamp.today() in daily use
LIMIT_DAYS = 10

ledger = pd.read_excel("samples/request_ledger.xlsx", sheet_name="依頼台帳")
ledger["依頼日"] = pd.to_datetime(ledger["依頼日"])
ledger["経過日数"] = (AS_OF - ledger["依頼日"]).dt.days
is_open = ledger["状態"].isin(["受付", "確認中"])
flag = (ledger["状態"] == "差戻し") | (is_open & (ledger["経過日数"] > LIMIT_DAYS))
cols = ["依頼ID", "文書名", "種別", "状態", "依頼日", "経過日数", "担当者"]
todo = ledger.loc[flag, cols].sort_values(["担当者", "経過日数"], ascending=[True, False])

with pd.ExcelWriter("out_followup.xlsx") as writer:
    for owner, rows in todo.groupby("担当者"):
        rows.drop(columns="担当者").to_excel(writer, sheet_name=str(owner), index=False)
print("rows to follow up:", len(todo))
print(todo.groupby("担当者").size().to_string())
Output(2026-09-12)
rows to follow up: 26
担当者
担当A    10
担当B     7
担当C     3
担当D     6
Example instruction for Copilot

Using pandas, take from request_ledger.xlsx (sheet 依頼台帳) the rows whose status is 差戻し, and the rows still 受付 or 確認中 whose days from request date to a reference date exceed a limit, and write them to a new workbook with one sheet per owner. Make the reference date and the limit constants.

Caution

Excel restricts sheet names (for example, certain characters are not allowed). If owner names in your ledger contain such characters, replace them before writing. The original ledger is only read; results go to a new workbook.

Availability
Uses pandas and openpyxl in a local virtual environment.
Requires
pandas 3.0.5, openpyxl 3.1.5
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, pandas 3.0.5, openpyxl 3.1.5)
Source
pandas, "pandas.ExcelWriter"
pandas, "pandas.read_excel"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Class for writing DataFrame objects into excel sheets.

03Match request mail against the ledger and list the gapsVerified

Picks request IDs from the subjects of Inbox messages, counts messages and finds the latest per request, and compares them with the ledger, listing requests still receiving mail after completion and open requests with no mail at all.

Approach and steps
  1. Fetch only subject and received time with $select, following @odata.nextLink through every page
  2. Pick request IDs from subjects with a regular expression and count messages and the latest time per request (mail_by_request)
  3. Read the ledger CSV and decide the gaps from status and completion date (compare)
  4. Counting and comparing are separate functions, so test them on the sample JSON and ledger before running against your tenant
PythonSign-in required (Microsoft Entra)
"""Match request mail with the ledger: mail after completion, and open requests without any mail."""
import asyncio
import csv
import os
import re
from collections import defaultdict

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
    MessagesRequestBuilder,
)

SCOPES = ["Mail.ReadBasic"]  # subject and dates only; no message bodies
REQUEST_ID = re.compile(r"R-\d{4}-\d{3}")


def mail_by_request(messages):
    """messages: [(subject, received ISO string)] -> {request_id: (count, latest)}"""
    seen = defaultdict(list)
    for subject, received in messages:
        m = REQUEST_ID.search(subject or "")
        if m:
            seen[m.group(0)].append(received)
    return {rid: (len(v), max(v)) for rid, v in seen.items()}


def compare(ledger_rows, mail):
    out = []
    for r in ledger_rows:
        rid, state, done = r["依頼ID"], r["状態"], r.get("完了日") or ""
        count, latest = mail.get(rid, (0, ""))
        if state == "完了" and done and latest[:10] > done:
            out.append((rid, "mail after completion", count, latest))
        if count == 0 and state in ("受付", "確認中"):
            out.append((rid, "open request without mail", 0, ""))
    return out


async def fetch_messages(client):
    builder = client.me.mail_folders.by_mail_folder_id("inbox").messages
    query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
        select=["subject", "receivedDateTime"], top=100)
    page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
    rows = []
    while page:
        rows += [(m.subject, m.received_date_time.isoformat() if m.received_date_time else "") for m in page.value or []]
        if not page.odata_next_link:
            break
        page = await builder.with_url(page.odata_next_link).get()
    return rows


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    mail = mail_by_request(await fetch_messages(client))
    with open(os.environ.get("LEDGER_CSV", "ledger.csv"), encoding="utf-8", newline="") as f:
        findings = compare(list(csv.DictReader(f)), mail)
    for row in findings:
        print(*row, sep=" | ")
    print("findings:", len(findings))


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
request IDs in mail: 17 / findings: 16
R-2026-002 | open request without mail | 0 | 
R-2026-004 | mail after completion | 1 | 2026-09-05T16:00:00Z
R-2026-006 | open request without mail | 0 | 
R-2026-007 | open request without mail | 0 | 
R-2026-008 | open request without mail | 0 | 
Example instruction for Copilot

With msgraph-sdk, read every page of Inbox messages selecting only subject and receivedDateTime, count messages and the latest time per request ID of the form R-2026-001 in the subject, and compare with a ledger CSV to list mail after completion and open requests without mail. Keep counting and comparing in separate functions and use the Mail.ReadBasic permission.

Caution

Request IDs are only found when subjects follow the convention. Message bodies are not read by design, so mail without an ID in the subject is not counted. It reads a personal mailbox; check your organization's rules and the app registration and consent before use.

Availability
Microsoft Graph (v1.0), delegated Mail.ReadBasic permission. Requires an app registered in Microsoft Entra.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Mail.ReadBasic (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "Paging Microsoft Graph data in your app"
Microsoft Learn, "List messages"
Verified
2026-09-12 (v1)
Supporting passages from the sources
When there's at least one more page of data available, Microsoft Graph returns an @odata.nextLink property in the response that contains a URL to the next page of results.
To improve the operation response time, use $select to specify the exact properties you need; see example 1 below.

04Compare document library column values with the ledgerVerified

Reads each file's columns (DocId and Status) in a document library with Microsoft Graph and compares them with the ledger, writing files with an empty DocId, files not in the ledger and status mismatches to a CSV.

Approach and steps
  1. Read list items with expand=fields(select=...) to fetch only the columns you need
  2. Follow @odata.nextLink through every page and turn each row into a dictionary
  3. Build a request ID → status dictionary from the ledger CSV
  4. Keep the comparison in a mismatches function and test it on the sample JSON before use
PythonSign-in required (Microsoft Entra)
"""Compare a document library's Status column with the request ledger and list mismatches (report only)."""
import asyncio
import csv
import os

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.sites.item.lists.item.items.items_request_builder import ItemsRequestBuilder

SCOPES = ["Sites.Read.All"]
COLUMNS = ["FileLeafRef", "DocId", "Status"]


def mismatches(library_rows, ledger):
    """library_rows: [{FileLeafRef, DocId, Status, webUrl}]; ledger: {request ID: status}"""
    out = []
    for r in library_rows:
        doc_id, name = r.get("DocId"), r.get("FileLeafRef")
        if not doc_id:
            out.append((name, "", "DocId is empty", r.get("webUrl")))
        elif doc_id not in ledger:
            out.append((name, doc_id, "not in the ledger", r.get("webUrl")))
        elif (r.get("Status") or "") != ledger[doc_id]:
            out.append((name, doc_id, "library %s / ledger %s" % (r.get("Status"), ledger[doc_id]), r.get("webUrl")))
    return out


async def fetch_rows(client):
    items_rb = client.sites.by_site_id(os.environ["SITE_ID"]).lists.by_list_id(os.environ["LIST_ID"]).items
    query = ItemsRequestBuilder.ItemsRequestBuilderGetQueryParameters(
        expand=["fields(select=%s)" % ",".join(COLUMNS)])
    page = await items_rb.get(request_configuration=RequestConfiguration(query_parameters=query))
    rows = []
    while page:
        for it in page.value or []:
            f = it.fields.additional_data if it.fields else {}
            rows.append({c: f.get(c) for c in COLUMNS} | {"webUrl": it.web_url})
        if not page.odata_next_link:
            break
        page = await items_rb.with_url(page.odata_next_link).get()
    return rows


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    rows = await fetch_rows(client)
    with open(os.environ.get("LEDGER_CSV", "ledger.csv"), encoding="utf-8", newline="") as f:
        ledger = {r["依頼ID"]: r["状態"] for r in csv.DictReader(f)}
    found = mismatches(rows, ledger)
    with open("out_library_vs_ledger.csv", "w", encoding="utf-8-sig", newline="") as f:
        csv.writer(f).writerows([("file", "doc_id", "problem", "webUrl")] + found)
    print("library rows:", len(rows), "/ mismatches:", len(found))


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
library rows: 15 / mismatches: 12
文書02.pdf | R-2026-002 | library None / ledger 受付
文書03.pdf | R-2026-003 | library 確認中 / ledger 差戻し
文書05.pdf |  | DocId is empty
文書06.pdf | R-2026-006 | library None / ledger 確認中
文書07.pdf | R-2026-007 | library None / ledger 確認中
Example instruction for Copilot

With msgraph-sdk, read every page of a SharePoint document library's list items with expand=fields(select=FileLeafRef,DocId,Status), compare with the ledger CSV status, and write three kinds of findings to a CSV: empty DocId, not in the ledger, and different status. Do not write anything back; use read-only permission.

Caution

Internal column names differ per library and can differ from display names; check them in the library's column list first. This example only reports and never changes column values.

Availability
Microsoft Graph (v1.0), delegated Sites.Read.All (or Sites.Selected to limit it to specific sites; see the SharePoint / OneDrive × Python page).
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Sites.Read.All (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "List items"
Verified
2026-09-12 (v1)
Supporting passages from the sources
GET /sites/{site-id}/lists/{list-id}/items?expand=fields

05Check only the Word documents that changed since the last runVerified

Uses a drive delta query to pick only the .docx files changed since the last run, downloads their content and runs the wording check, keeping the deltaLink in a file for next time so documents are not all reread every run.

Approach and steps
  1. Start from the saved deltaLink if there is one, otherwise from root/delta
  2. Drop deleted items and folders and keep only items whose names end in .docx
  3. Download each item's content and pass it to python-docx through BytesIO to check the paragraphs
  4. Save the deltaLink from the last page so the next run continues from there
PythonSign-in required (Microsoft Entra)
"""Check only the .docx files that changed since the last run (drive delta + python-docx); report only."""
import asyncio
import csv
import io
import json
import os
import unicodedata
from pathlib import Path

from azure.identity import DeviceCodeCredential
from docx import Document
from msgraph import GraphServiceClient

SCOPES = ["Files.Read.All"]
STATE = Path(os.environ.get("DELTA_STATE", "out_delta_state.json"))


def normalize(text):
    return unicodedata.normalize("NFKC", text).casefold()


def load_terms(path):
    with open(path, encoding="utf-8-sig", newline="") as f:
        return [(r["term"], r["action"], normalize(r["term"])) for r in csv.DictReader(f) if r["term"].strip()]


def check_docx(data, terms):
    """data: the bytes of a .docx file -> [(where, term, action)]"""
    doc = Document(io.BytesIO(data))
    hits = []
    for i, p in enumerate(doc.paragraphs, 1):
        key = normalize(p.text)
        hits += [("paragraph %d" % i, term, action) for term, action, k in terms if k in key]
    return hits


async def changed_docx(builder, drive_id):
    first = "https://graph.microsoft.com/v1.0/drives/%s/root/delta" % drive_id
    link = json.loads(STATE.read_text(encoding="utf-8"))["deltaLink"] if STATE.exists() else first
    page, items = await builder.with_url(link).get(), []
    while True:
        items += [i for i in page.value or []
                  if i.deleted is None and i.file is not None and (i.name or "").lower().endswith(".docx")]
        if not page.odata_next_link:
            return items, page.odata_delta_link
        page = await builder.with_url(page.odata_next_link).get()


async def main():
    drive_id = os.environ["DRIVE_ID"]
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    drive = client.drives.by_drive_id(drive_id)
    items, delta_link = await changed_docx(drive.items.by_drive_item_id("root").delta, drive_id)
    terms = load_terms(os.environ.get("TERMS_CSV", "style_terms.csv"))
    for item in items:
        data = await drive.items.by_drive_item_id(item.id).content.get()
        for where, term, action in check_docx(data, terms):
            print(item.name, where, term, action, sep=" | ")
    STATE.write_text(json.dumps({"deltaLink": delta_link}), encoding="utf-8")
    print("changed .docx files:", len(items))


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
hits in doc_a.docx paragraphs: 6
paragraph 5 | 最高 | 根拠を確認する
paragraph 5 | 業界初 | 根拠を確認する
paragraph 5 | 絶対 | 言い換える
Example instruction for Copilot

With msgraph-sdk, read a drive's delta, download only the changed .docx files, open each with python-docx through BytesIO and match its paragraphs against the terms in style_terms.csv. Save the final deltaLink to a file and continue from it next time. Use only the read permission Files.Read.All.

Caution

When a saved deltaLink can no longer be used, you need to fall back to reading everything again (see the delta query tip on the SharePoint / OneDrive × Python page). This example checks paragraphs only, not table cells, and only reports; it never modifies documents.

Availability
Microsoft Graph (v1.0), delegated Files.Read.All permission; python-docx in a local virtual environment.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3, python-docx 1.2.0
Permissions
Files.Read.All (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3, python-docx 1.2.0)
Source
Microsoft Learn, "driveItem: delta"
python-docx, "Document objects (API)"
Verified
2026-09-12 (v1)
Supporting passages from the sources
The service starts enumerating the drive's hierarchy, returning pages of items and either an @odata.nextLink or an @odata.deltaLink, as described below.
to a .docx file (a string) or a file-like object.