DEV Community

KazKN
KazKN

Posted on

Turn a Zalo Export into Clean pandas DataFrames

Zalo export records do not belong in one flat table. A group is not a member, a
member is not a profile, and a message is not a conversation summary. Flattening
all five record types into one spreadsheet creates duplicate columns and hides
the relationships your analysis needs.

This tutorial retrieves an Apify Dataset, builds one pandas DataFrame per record
type, joins group members to visible profiles, and writes a multi-sheet Excel
workbook.

Disclosure: I built the Zalo Data Exporter used below. I may earn revenue
from Apify when the Actor is run through links in this article, at no extra
cost to you.

Install the data tools

pip install apify-client pandas openpyxl
Enter fullscreen mode Exit fullscreen mode

Keep the Apify token outside the notebook or script:

export APIFY_API_TOKEN="your-token-from-apify-console"
Enter fullscreen mode Exit fullscreen mode

The first connection for a saved account alias still requires a temporary QR
approval in Apify Console. Once connected, later calls can reuse the encrypted
login while the session remains valid.

Open the Zalo Data Exporter

Retrieve the Dataset

Start with a bounded group roster:

import os

import pandas as pd
from apify_client import ApifyClient


client = ApifyClient(os.environ["APIFY_API_TOKEN"])

run = client.actor("kazkn/zalo-member-profile-exporter").call(
    run_input={
        "exportMode": "groupRoster",
        "accountAlias": "main",
        "maxGroups": 1,
        "maxMembersPerGroup": 100,
    }
)

records = client.dataset(run["defaultDatasetId"]).list_items().items
print(f"Dataset records: {len(records)}")
Enter fullscreen mode Exit fullscreen mode

The result can contain profile, group, groupMember, conversation, and
message rows. The selected preset determines which families appear.

Build one DataFrame per record type

record_types = (
    "profile",
    "group",
    "groupMember",
    "conversation",
    "message",
)

frames = {
    record_type: pd.DataFrame(
        record for record in records
        if record.get("recordType") == record_type
    )
    for record_type in record_types
}

for record_type, frame in frames.items():
    print(f"{record_type}: {len(frame)} rows")
Enter fullscreen mode Exit fullscreen mode

An empty DataFrame is expected when the preset did not request that record
family. Do not treat it as an extraction error without checking the chosen
preset and the run summary.

Preserve IDs as strings

Spreadsheet tools sometimes convert long identifiers into floating-point
numbers or scientific notation. Cast identifiers before saving:

id_columns = {
    "profile": ["userId"],
    "group": ["groupId"],
    "groupMember": ["groupId", "userId"],
    "conversation": ["conversationId"],
    "message": ["messageId", "threadId", "senderId"],
}

for record_type, columns in id_columns.items():
    frame = frames[record_type]
    for column in columns:
        if column in frame.columns:
            frame[column] = frame[column].astype("string")
Enter fullscreen mode Exit fullscreen mode

Missing values remain nullable strings. That matters for profile data because
Zalo does not expose every field for every visible account.

Join group members to visible profiles

The groupMember table keeps group membership. The profile table holds
profile fields that were visible during the run. Join them on userId without
dropping members that have no profile row.

members = frames["groupMember"].copy()
profiles = frames["profile"].copy()

profile_columns = [
    column for column in (
        "userId",
        "zaloName",
        "avatarUrl",
        "relationship",
    )
    if column in profiles.columns
]

if not members.empty and "userId" in members.columns:
    enriched_members = members.merge(
        profiles[profile_columns],
        on="userId",
        how="left",
        suffixes=("", "_profile"),
        validate="many_to_one",
    )
else:
    enriched_members = members
Enter fullscreen mode Exit fullscreen mode

The left join is deliberate. A member row proves visible membership in a joined
group. A missing profile field only means that the field was not returned in
that run.

Keep completeness with the analytical table

Group and conversation records can carry status fields explaining truncation or
the boundary reached by the read. Preserve those fields in any table used for
reporting.

For chat analysis, inspect conversation rows before counting messages:

conversations = frames["conversation"]

status_columns = [
    column for column in (
        "conversationId",
        "title",
        "completeness",
        "terminationReason",
    )
    if column in conversations.columns
]

conversation_status = conversations[status_columns].copy()
Enter fullscreen mode Exit fullscreen mode

A conversation that stopped at PARTIAL_LIMIT should not be reported as a full
history. The configured message count, date boundary, Zalo Web response, or a
safety ceiling can stop the read.

Write separate Excel sheets

sheet_frames = {
    "Group members": enriched_members,
    "Groups": frames["group"],
    "Profiles": frames["profile"],
    "Conversations": frames["conversation"],
    "Messages": frames["message"],
}

with pd.ExcelWriter("zalo-export.xlsx", engine="openpyxl") as writer:
    for sheet_name, frame in sheet_frames.items():
        if not frame.empty:
            frame.to_excel(writer, sheet_name=sheet_name, index=False)
Enter fullscreen mode Exit fullscreen mode

This layout is easier to audit than one wide sheet. It also maps directly to
the focused Dataset views exposed by the Actor.

Add a small validation report

Before sending the workbook to another team, record the row counts and duplicate
checks:

validation = []

for record_type, frame in frames.items():
    validation.append({
        "recordType": record_type,
        "rows": len(frame),
        "columns": len(frame.columns),
    })

if "recordKey" in enriched_members.columns:
    duplicate_member_keys = int(
        enriched_members["recordKey"].duplicated().sum()
    )
else:
    duplicate_member_keys = 0

print(pd.DataFrame(validation))
print("Duplicate group-member record keys:", duplicate_member_keys)
Enter fullscreen mode Exit fullscreen mode

The validation output does not prove universal completeness. It tells you what
this bounded run delivered and whether your local transformation introduced
obvious duplicates.

Data pipeline rules that prevent bad reports

  • Keep recordType in the raw landing table.
  • Preserve identifier columns as strings.
  • Use left joins when enriching membership rows.
  • Carry completeness and termination fields into chat reports.
  • Store the source run ID and collection time with each delivery.
  • Never infer phone numbers or hidden profile fields from missing values.

The useful output is not simply an Excel file. It is a set of typed tables whose
relationships and limits remain visible after the export leaves Apify.

👉 Create a bounded Zalo Dataset for pandas

Top comments (0)