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
Keep the Apify token outside the notebook or script:
export APIFY_API_TOKEN="your-token-from-apify-console"
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.
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)}")
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")
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")
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
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()
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)
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)
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
recordTypein 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.
Top comments (0)